diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 070fd01..afe9eff 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.room) kotlin("plugin.serialization") version "2.0.21" + id("kotlin-parcelize") } val versionMajor = 1 @@ -101,6 +102,9 @@ dependencies { implementation(libs.work.runtime) implementation(libs.work.runtime.ktx) implementation(libs.kotlinx.coroutines.guava) + implementation(libs.androidx.adaptive) + implementation(libs.androidx.adaptive.layout) + implementation(libs.androidx.adaptive.navigation) implementation(libs.logging.interceptor) implementation(libs.androidx.browser) diff --git a/app/src/main/java/dev/itsvic/parceltracker/MainActivity.kt b/app/src/main/java/dev/itsvic/parceltracker/MainActivity.kt index 518b8e4..60c5811 100644 --- a/app/src/main/java/dev/itsvic/parceltracker/MainActivity.kt +++ b/app/src/main/java/dev/itsvic/parceltracker/MainActivity.kt @@ -31,10 +31,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -43,27 +40,15 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute -import dev.itsvic.parceltracker.api.ParcelHistoryItem -import dev.itsvic.parceltracker.api.ParcelNonExistentException -import dev.itsvic.parceltracker.api.Status -import dev.itsvic.parceltracker.api.getParcel import dev.itsvic.parceltracker.db.Parcel -import dev.itsvic.parceltracker.db.ParcelStatus -import dev.itsvic.parceltracker.db.ParcelWithStatus -import dev.itsvic.parceltracker.api.Parcel as APIParcel import dev.itsvic.parceltracker.db.demoModeParcels import dev.itsvic.parceltracker.ui.theme.ParcelTrackerTheme import dev.itsvic.parceltracker.ui.views.AddEditParcelView -import dev.itsvic.parceltracker.ui.views.HomeView -import dev.itsvic.parceltracker.ui.views.ParcelView import dev.itsvic.parceltracker.ui.views.SettingsView import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.serialization.Serializable -import okio.IOException -import java.time.LocalDateTime -import java.time.ZoneId class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -131,8 +116,8 @@ object HomePage @Serializable object SettingsPage -@Serializable -data class ParcelPage(val parcelDbId: Int) +//@Serializable +//data class ParcelPage(val parcelDbId: Int) @Serializable object AddParcelPage @@ -149,11 +134,11 @@ fun ParcelAppNavigation(parcelToOpen: Int) { val demoMode by context.dataStore.data.map { it[DEMO_MODE] == true }.collectAsState(false) LaunchedEffect(parcelToOpen) { - if (parcelToOpen != -1) { - navController.navigate(route = ParcelPage(parcelToOpen)) { - popUpTo(HomePage) - } - } +// if (parcelToOpen != -1) { +// navController.navigate(route = ParcelPage(parcelToOpen)) { +// popUpTo(HomePage) +// } +// } } val animDuration = 300 @@ -183,16 +168,21 @@ fun ParcelAppNavigation(parcelToOpen: Int) { }, ) { composable { - val parcels = if (demoMode) - derivedStateOf { demoModeParcels } - else - db.parcelDao().getAllWithStatus().collectAsState(initial = emptyList()) - - HomeView( - parcels = parcels.value, - onNavigateToAddParcel = { navController.navigate(route = AddParcelPage) }, - onNavigateToParcel = { navController.navigate(route = ParcelPage(it.id)) }, +// val parcels = if (demoMode) +// derivedStateOf { demoModeParcels } +// else +// db.parcelDao().getAllWithStatus().collectAsState(initial = emptyList()) +// +// HomeView( +// parcels = parcels.value, +// onNavigateToAddParcel = { navController.navigate(route = AddParcelPage) }, +// onNavigateToParcel = { navController.navigate(route = ParcelPage(it.id)) }, +// onNavigateToSettings = { navController.navigate(route = SettingsPage) }, +// ) + ParcelListDetailRoute( + demoMode = demoMode, onNavigateToSettings = { navController.navigate(route = SettingsPage) }, + onNavigateToAddParcel = { navController.navigate(route = AddParcelPage) }, ) } @@ -202,107 +192,107 @@ fun ParcelAppNavigation(parcelToOpen: Int) { ) } - composable { backStackEntry -> - val route: ParcelPage = backStackEntry.toRoute() - val parcelWithStatus: ParcelWithStatus? by if (demoMode) - derivedStateOf { demoModeParcels[route.parcelDbId] } - else - db.parcelDao().getWithStatusById(route.parcelDbId).collectAsState(null) - var apiParcel: APIParcel? by remember { mutableStateOf(null) } - - val dbParcel = parcelWithStatus?.parcel - - LaunchedEffect(parcelWithStatus) { - if (dbParcel != null) { - launch(Dispatchers.IO) { - try { - apiParcel = getParcel( - dbParcel.parcelId, - dbParcel.postalCode, - dbParcel.service - ) - - if (!demoMode) { - // update parcel status - val zone = ZoneId.systemDefault() - val lastChange = - apiParcel!!.history.first().time.atZone(zone).toInstant() - val status = ParcelStatus( - dbParcel.id, - apiParcel!!.currentStatus, - lastChange, - ) - if (parcelWithStatus?.status == null) { - db.parcelStatusDao().insert(status) - } else { - db.parcelStatusDao().update(status) - } - } - } catch (e: IOException) { - Log.w("MainActivity", "Failed fetch: $e") - apiParcel = APIParcel( - dbParcel.parcelId, - listOf( - ParcelHistoryItem( - context.getString(R.string.network_failure_detail), - LocalDateTime.now(), - "" - ) - ), - Status.NetworkFailure - ) - } catch (_: ParcelNonExistentException) { - apiParcel = APIParcel( - dbParcel.parcelId, - listOf( - ParcelHistoryItem( - context.getString(R.string.parcel_doesnt_exist_detail), - LocalDateTime.now(), - "" - ) - ), - Status.NoData - ) - } - } - } - } - - if (apiParcel == null || dbParcel == null) - Box( - modifier = Modifier - .background(color = MaterialTheme.colorScheme.background) - .fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } - else - ParcelView( - apiParcel!!, - dbParcel.humanName, - dbParcel.service, - onBackPressed = { navController.popBackStack() }, - onEdit = { navController.navigate(EditParcelPage(dbParcel.id)) }, - onDelete = { - if (demoMode) { - Toast.makeText( - context, - context.getString(R.string.demo_mode_action_block), - Toast.LENGTH_SHORT - ).show() - return@ParcelView - } - - scope.launch(Dispatchers.IO) { - db.parcelDao().delete(dbParcel) - scope.launch { - navController.popBackStack(HomePage, false) - } - } - }, - ) - } +// composable { backStackEntry -> +// val route: ParcelPage = backStackEntry.toRoute() +// val parcelWithStatus: ParcelWithStatus? by if (demoMode) +// derivedStateOf { demoModeParcels[route.parcelDbId] } +// else +// db.parcelDao().getWithStatusById(route.parcelDbId).collectAsState(null) +// var apiParcel: APIParcel? by remember { mutableStateOf(null) } +// +// val dbParcel = parcelWithStatus?.parcel +// +// LaunchedEffect(parcelWithStatus) { +// if (dbParcel != null) { +// launch(Dispatchers.IO) { +// try { +// apiParcel = getParcel( +// dbParcel.parcelId, +// dbParcel.postalCode, +// dbParcel.service +// ) +// +// if (!demoMode) { +// // update parcel status +// val zone = ZoneId.systemDefault() +// val lastChange = +// apiParcel!!.history.first().time.atZone(zone).toInstant() +// val status = ParcelStatus( +// dbParcel.id, +// apiParcel!!.currentStatus, +// lastChange, +// ) +// if (parcelWithStatus?.status == null) { +// db.parcelStatusDao().insert(status) +// } else { +// db.parcelStatusDao().update(status) +// } +// } +// } catch (e: IOException) { +// Log.w("MainActivity", "Failed fetch: $e") +// apiParcel = APIParcel( +// dbParcel.parcelId, +// listOf( +// ParcelHistoryItem( +// context.getString(R.string.network_failure_detail), +// LocalDateTime.now(), +// "" +// ) +// ), +// Status.NetworkFailure +// ) +// } catch (e: ParcelNonExistentException) { +// apiParcel = APIParcel( +// dbParcel.parcelId, +// listOf( +// ParcelHistoryItem( +// context.getString(R.string.parcel_doesnt_exist_detail), +// LocalDateTime.now(), +// "" +// ) +// ), +// Status.NoData +// ) +// } +// } +// } +// } +// +// if (apiParcel == null || dbParcel == null) +// Box( +// modifier = Modifier +// .background(color = MaterialTheme.colorScheme.background) +// .fillMaxSize(), +// contentAlignment = Alignment.Center +// ) { +// CircularProgressIndicator() +// } +// else +// ParcelView( +// apiParcel!!, +// dbParcel.humanName, +// dbParcel.service, +// onBackPressed = { navController.popBackStack() }, +// onEdit = { navController.navigate(EditParcelPage(dbParcel.id)) }, +// onDelete = { +// if (demoMode) { +// Toast.makeText( +// context, +// context.getString(R.string.demo_mode_action_block), +// Toast.LENGTH_SHORT +// ).show() +// return@ParcelView +// } +// +// scope.launch(Dispatchers.IO) { +// db.parcelDao().delete(dbParcel) +// scope.launch { +// navController.popBackStack(HomePage, false) +// } +// } +// }, +// ) +// } composable { AddEditParcelView( @@ -319,12 +309,12 @@ fun ParcelAppNavigation(parcelToOpen: Int) { } scope.launch(Dispatchers.IO) { - val id = db.parcelDao().insert(it) - scope.launch { - navController.navigate(route = ParcelPage(id.toInt())) { - popUpTo(HomePage) - } - } +// val id = db.parcelDao().insert(it) +// scope.launch { +// navController.navigate(route = ParcelPage(id.toInt())) { +// popUpTo(HomePage) +// } +// } } }, ) diff --git a/app/src/main/java/dev/itsvic/parceltracker/ParcelListDetailRoute.kt b/app/src/main/java/dev/itsvic/parceltracker/ParcelListDetailRoute.kt new file mode 100644 index 0000000..22b2d97 --- /dev/null +++ b/app/src/main/java/dev/itsvic/parceltracker/ParcelListDetailRoute.kt @@ -0,0 +1,175 @@ +package dev.itsvic.parceltracker + +import android.content.Context +import android.os.Parcelable +import android.util.Log +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.layout.AnimatedPane +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole +import androidx.compose.material3.adaptive.navigation.NavigableListDetailPaneScaffold +import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import dev.itsvic.parceltracker.api.Parcel +import dev.itsvic.parceltracker.api.ParcelHistoryItem +import dev.itsvic.parceltracker.api.ParcelNonExistentException +import dev.itsvic.parceltracker.api.Status +import dev.itsvic.parceltracker.api.getParcel +import dev.itsvic.parceltracker.db.ParcelWithStatus +import dev.itsvic.parceltracker.db.demoModeParcels +import dev.itsvic.parceltracker.ui.views.HomeView +import dev.itsvic.parceltracker.ui.views.ParcelView +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import okio.IOException +import java.time.LocalDateTime + +@Parcelize +class ParcelInfo(val id: Int) : Parcelable + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +fun ParcelListDetailRoute( + demoMode: Boolean = true, + onNavigateToSettings: () -> Unit, + onNavigateToAddParcel: () -> Unit, +) { + val context = LocalContext.current + val db = ParcelApplication.db + val navigator = rememberListDetailPaneScaffoldNavigator() + val scope = rememberCoroutineScope() + + NavigableListDetailPaneScaffold( + navigator = navigator, + listPane = { + AnimatedPane( + enterTransition = fadeIn(tween(300)) + scaleIn(tween(500), 0.9f), + exitTransition = slideOutHorizontally( + tween(300), + targetOffsetX = { -it / 4 } + ) + fadeOut(tween(300)) + ) { + val parcels: List by if (demoMode) + remember { derivedStateOf { demoModeParcels } } + else + db.parcelDao().getAllWithStatus().collectAsState(emptyList()) + + HomeView( + parcels = parcels, + onNavigateToAddParcel = onNavigateToAddParcel, + onNavigateToSettings = onNavigateToSettings, + onNavigateToParcel = { + scope.launch { + navigator.navigateTo( + ListDetailPaneScaffoldRole.Detail, + ParcelInfo(it.id) + ) + } + } + ) + } + }, + + detailPane = { + AnimatedPane( + enterTransition = slideInHorizontally( + tween(300), + initialOffsetX = { it / 4 } + ) + fadeIn(tween(300)), + ) { + navigator.currentDestination?.contentKey?.let { parcelInfo -> + val parcelWithStatus: ParcelWithStatus? by + if (demoMode) + derivedStateOf { demoModeParcels[parcelInfo.id] } + else + db.parcelDao().getWithStatusById(parcelInfo.id).collectAsState(null) + val dbParcel = parcelWithStatus?.parcel + val apiParcel = dbParcel?.let { context.getParcelFlow(it).collectAsState(null) } + + if (apiParcel?.value == null) + Box( + modifier = Modifier + .background(MaterialTheme.colorScheme.background) + .fillMaxSize(), + contentAlignment = Alignment.Center + ) + { + CircularProgressIndicator() + } + else + ParcelView( + apiParcel.value!!, + dbParcel.humanName, + dbParcel.service, + canBack = navigator.canNavigateBack(), + onBackPressed = { + if (navigator.canNavigateBack()) { + scope.launch { + navigator.navigateBack() + } + } + }, + onEdit = {}, + onDelete = {}, + ) + } + } + } + ) +} + +fun Context.getParcelFlow(parcel: dev.itsvic.parceltracker.db.Parcel): Flow = flow { + try { + emit(getParcel(parcel.parcelId, parcel.postalCode, parcel.service)) + } catch (e: IOException) { + Log.w("ParcelListDetailRoute", "Failed fetch: $e") + emit( + Parcel( + parcel.parcelId, + listOf( + ParcelHistoryItem( + getString(R.string.network_failure_detail), + LocalDateTime.now(), + "" + ) + ), + Status.NetworkFailure + ) + ) + } catch (_: ParcelNonExistentException) { + emit( + Parcel( + parcel.parcelId, + listOf( + ParcelHistoryItem( + getString(R.string.parcel_doesnt_exist_detail), + LocalDateTime.now(), + "" + ) + ), + Status.NoData + ) + ) + } +} diff --git a/app/src/main/java/dev/itsvic/parceltracker/ui/views/ParcelView.kt b/app/src/main/java/dev/itsvic/parceltracker/ui/views/ParcelView.kt index e80d9cc..45169ec 100644 --- a/app/src/main/java/dev/itsvic/parceltracker/ui/views/ParcelView.kt +++ b/app/src/main/java/dev/itsvic/parceltracker/ui/views/ParcelView.kt @@ -50,6 +50,7 @@ fun ParcelView( parcel: Parcel, humanName: String, service: Service, + canBack: Boolean, onBackPressed: () -> Unit, onEdit: () -> Unit, onDelete: () -> Unit, @@ -64,9 +65,10 @@ fun ParcelView( Text(humanName) }, navigationIcon = { - IconButton(onClick = onBackPressed) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, "Go back") - } + if (canBack) + IconButton(onClick = onBackPressed) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Go back") + } }, actions = { IconButton(onClick = { expanded = !expanded }) { @@ -177,6 +179,7 @@ private fun ParcelViewPreview() { parcel, "My precious package", Service.EXAMPLE, + canBack = true, onBackPressed = {}, onEdit = {}, onDelete = {}, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f006e07..18cafbb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,7 @@ work = "2.10.0" kotlinxCoroutinesGuava = "1.10.1" material3 = "1.4.0-alpha09" browser = "1.8.0" +adaptive = "1.1.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -57,6 +58,9 @@ work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version kotlinx-coroutines-guava = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-guava", version.ref = "kotlinxCoroutinesGuava" } logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" } +androidx-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive", version.ref = "adaptive" } +androidx-adaptive-layout = { group = "androidx.compose.material3.adaptive", name = "adaptive-layout", version.ref = "adaptive" } +androidx-adaptive-navigation = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation", version.ref = "adaptive" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }