From b9635fb2daa829a35f0399ab02cda871ec8193c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Mlynari=C4=8D?= Date: Thu, 26 Jun 2025 10:50:30 +0200 Subject: [PATCH 01/13] Update material3 to prevent recompositions --- Fruitties/androidApp/build.gradle.kts | 1 + Fruitties/gradle/libs.versions.toml | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Fruitties/androidApp/build.gradle.kts b/Fruitties/androidApp/build.gradle.kts index fed9ee0..38187db 100644 --- a/Fruitties/androidApp/build.gradle.kts +++ b/Fruitties/androidApp/build.gradle.kts @@ -76,6 +76,7 @@ dependencies { implementation(libs.compose.ui) implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.material3) + implementation(libs.androidx.material.icons.core) implementation(libs.androidx.activity.compose) implementation(libs.androidx.paging.compose.android) implementation(libs.androidx.lifecycle.viewmodel.compose) diff --git a/Fruitties/gradle/libs.versions.toml b/Fruitties/gradle/libs.versions.toml index 6009169..12072be 100644 --- a/Fruitties/gradle/libs.versions.toml +++ b/Fruitties/gradle/libs.versions.toml @@ -38,11 +38,14 @@ kermit = "2.0.4" runner = "1.6.2" core = "1.6.1" junit = "1.2.1" +materialIconsCore = "1.7.8" +material3 = "1.4.0-alpha16" [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } androidx-datastore-core-okio = { group = "androidx.datastore", name = "datastore-core-okio", version.ref = "dataStore" } androidx-datastore-preferences-core = { group = "androidx.datastore", name = "datastore-preferences-core", version.ref = "dataStore" } +androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core", version.ref = "materialIconsCore" } androidx-paging-common = { module = "androidx.paging:paging-common", version.ref = "androidx-paging" } androidx-paging-compose-android = { group = "androidx.paging", name = "paging-compose-android", version.ref = "pagingComposeAndroid" } androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "androidx-room" } @@ -54,7 +57,7 @@ compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeB compose-ui = { module = "androidx.compose.ui:ui" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } -compose-material3 = { module = "androidx.compose.material3:material3" } +compose-material3 = { module = "androidx.compose.material3:material3", version.ref="material3" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlinx-atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "atomicfu" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } From d3abf2afe5c97c74e41ff29e8e9d709fb33ea1cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Mlynari=C4=8D?= Date: Thu, 26 Jun 2025 10:50:50 +0200 Subject: [PATCH 02/13] Add FruittieScreen + connect cart actions --- .../example/fruitties/android/MainActivity.kt | 17 +++ .../fruitties/android/ui/CartScreen.kt | 56 +++++++- .../fruitties/android/ui/FruittieScreen.kt | 130 ++++++++++++++++++ .../fruitties/android/ui/ListScreen.kt | 121 ++++++++-------- .../src/main/res/values/strings.xml | 6 + .../com/example/fruitties/DataRepository.kt | 15 ++ .../example/fruitties/database/FruittieDao.kt | 3 + .../fruitties/viewmodel/CartViewModel.kt | 13 ++ .../fruitties/viewmodel/FruittieViewModel.kt | 55 ++++++++ 9 files changed, 349 insertions(+), 67 deletions(-) create mode 100644 Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/FruittieScreen.kt create mode 100644 Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/FruittieViewModel.kt diff --git a/Fruitties/androidApp/src/main/java/com/example/fruitties/android/MainActivity.kt b/Fruitties/androidApp/src/main/java/com/example/fruitties/android/MainActivity.kt index 0f70bb4..03737de 100644 --- a/Fruitties/androidApp/src/main/java/com/example/fruitties/android/MainActivity.kt +++ b/Fruitties/androidApp/src/main/java/com/example/fruitties/android/MainActivity.kt @@ -34,6 +34,7 @@ import androidx.navigation3.runtime.rememberSavedStateNavEntryDecorator import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.rememberSceneSetupNavEntryDecorator import com.example.fruitties.android.ui.CartScreen +import com.example.fruitties.android.ui.FruittieScreen import com.example.fruitties.android.ui.ListScreen import kotlinx.serialization.Serializable @@ -43,6 +44,9 @@ data object ListScreenKey : NavKey @Serializable data object CartScreenKey : NavKey +@Serializable +data class FruittieScreenKey(val id: Long) : NavKey + class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -75,11 +79,23 @@ fun NavApp() { entryProvider = entryProvider { entry { ListScreen( + onFruittieClick = { + backStack.add(FruittieScreenKey(it.id)) + }, onClickViewCart = { backStack.add(CartScreenKey) }, ) } + entry { + FruittieScreen( + fruittieId = it.id, + onNavBarBack = { + backStack.removeIf { it is FruittieScreenKey } + }, + ) + } + entry { CartScreen( onNavBarBack = { @@ -90,3 +106,4 @@ fun NavApp() { }, ) } + diff --git a/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/CartScreen.kt b/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/CartScreen.kt index 0c6985e..f1e0be9 100644 --- a/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/CartScreen.kt +++ b/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/CartScreen.kt @@ -17,6 +17,7 @@ package com.example.fruitties.android.ui import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides @@ -25,6 +26,7 @@ import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -32,8 +34,12 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonColors +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -43,12 +49,15 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.example.fruitties.android.R import com.example.fruitties.android.di.App +import com.example.fruitties.model.CartItemDetails import com.example.fruitties.viewmodel.CartViewModel import com.example.fruitties.viewmodel.creationExtras @@ -81,7 +90,7 @@ fun CartScreen(onNavBarBack: () -> Unit) { } }, title = { - Text(text = stringResource(R.string.frutties)) + Text(text = stringResource(R.string.cart)) }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.primary, @@ -108,14 +117,17 @@ fun CartScreen(onNavBarBack: () -> Unit) { val cartItemCount = cartState.totalItemCount Text( text = "Cart has $cartItemCount items", - modifier = Modifier.padding(8.dp), ) + HorizontalDivider() LazyColumn( modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, ) { items(cartState.cartDetails) { cartItem -> - Text(text = "${cartItem.fruittie.name}: ${cartItem.count}") + CartItem( + cartItem = cartItem, + decreaseCountClick = viewModel::decreaseCountClick, + increaseCountClick = viewModel::increaseCountClick, + ) } item { Spacer( @@ -128,3 +140,39 @@ fun CartScreen(onNavBarBack: () -> Unit) { } } } + +@Composable +fun CartItem( + cartItem: CartItemDetails, + increaseCountClick: (CartItemDetails) -> Unit, + decreaseCountClick: (CartItemDetails) -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = "${cartItem.count}x") + Spacer(Modifier.width(8.dp)) + Text(text = cartItem.fruittie.name) + Spacer(Modifier.weight(1f)) + FilledIconButton( + onClick = { decreaseCountClick(cartItem) }, + colors = IconButtonDefaults.filledIconButtonColors(containerColor = Color.Red), + ) { + Text( + text = "-", + color = MaterialTheme.colorScheme.onPrimary, + textAlign = TextAlign.Center, + ) + } + FilledIconButton( + onClick = { increaseCountClick(cartItem) }, + colors = IconButtonDefaults.filledIconButtonColors(containerColor = Color.Green), + ) { + Text( + text = "+", + color = MaterialTheme.colorScheme.onPrimary, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/FruittieScreen.kt b/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/FruittieScreen.kt new file mode 100644 index 0000000..d9aaf20 --- /dev/null +++ b/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/FruittieScreen.kt @@ -0,0 +1,130 @@ +package com.example.fruitties.android.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ShoppingCart +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.fruitties.android.R +import com.example.fruitties.android.di.App +import com.example.fruitties.model.Fruittie +import com.example.fruitties.viewmodel.FruittieViewModel +import com.example.fruitties.viewmodel.FruittieViewModel.Companion.FRUITTIE_ID_KEY +import com.example.fruitties.viewmodel.creationExtras + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FruittieScreen( + fruittieId: Long, + onNavBarBack: () -> Unit +) { + val app = LocalContext.current.applicationContext as App + + val viewModel: FruittieViewModel = viewModel( + factory = FruittieViewModel.Factory, + extras = creationExtras(app.container) { + set(FRUITTIE_ID_KEY, fruittieId) + }, + ) + + val state = viewModel.state.collectAsState().value + + FruittieScreen( + state = state, + onNavBarBack = onNavBarBack, + addToCart = { viewModel.addToCard(it) } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FruittieScreen( + state: FruittieViewModel.State, + addToCart: (Fruittie) -> Unit, + onNavBarBack: () -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + (state as? FruittieViewModel.State.Content)?.fruittie?.name + ?: stringResource(R.string.loading) + ) + }, + actions = { + val inCart = (state as? FruittieViewModel.State.Content)?.inCart ?: 0 + Text(stringResource(R.string.in_cart, inCart)) + Spacer(Modifier.width(8.dp)) + }, + navigationIcon = { + IconButton(onClick = onNavBarBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.navigate_back), + ) + } + }, + ) + }, + floatingActionButton = { + // Check if state is loaded + if (state !is FruittieViewModel.State.Content) return@Scaffold + + FloatingActionButton( + shape = MaterialTheme.shapes.extraLarge, + onClick = { addToCart(state.fruittie) } + ) { + Row( + modifier = Modifier.padding( + horizontal = 16.dp + ), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Filled.ShoppingCart, + contentDescription = null + ) + Spacer(Modifier.width(8.dp)) + Text(text = stringResource(R.string.add_to_cart)) + } + } + } + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .padding(it) + .fillMaxSize() + ) { + when (state) { + FruittieViewModel.State.Loading -> CircularProgressIndicator() + is FruittieViewModel.State.Content -> { + Text(state.fruittie.fullName) + Text(state.fruittie.calories) + } + } + } + } +} \ No newline at end of file diff --git a/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/ListScreen.kt b/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/ListScreen.kt index fc1703f..9f5159b 100644 --- a/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/ListScreen.kt +++ b/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/ListScreen.kt @@ -16,33 +16,34 @@ package com.example.fruitties.android.ui +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ShoppingCart import androidx.compose.material3.Button -import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -57,7 +58,10 @@ import com.example.fruitties.viewmodel.creationExtras @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ListScreen(onClickViewCart: () -> Unit = {}) { +fun ListScreen( + onClickViewCart: () -> Unit, + onFruittieClick: (Fruittie) -> Unit, +) { // Instantiate a ViewModel with a dependency on the AppContainer. // To make ViewModel compatible with KMP, the ViewModel factory must // create an instance without referencing the Android Application. @@ -70,69 +74,54 @@ fun ListScreen(onClickViewCart: () -> Unit = {}) { ) val uiState by viewModel.homeUiState.collectAsState() - + val topAppBarScrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() Scaffold( topBar = { - CenterAlignedTopAppBar( + LargeTopAppBar( + scrollBehavior = topAppBarScrollBehavior, title = { Text(text = stringResource(R.string.frutties)) - }, - colors = TopAppBarColors( - containerColor = MaterialTheme.colorScheme.primary, - scrolledContainerColor = MaterialTheme.colorScheme.primary, - navigationIconContentColor = MaterialTheme.colorScheme.onPrimary, - titleContentColor = MaterialTheme.colorScheme.onPrimary, - actionIconContentColor = MaterialTheme.colorScheme.onPrimary, - ), + } ) }, - contentWindowInsets = WindowInsets.safeDrawing.only( - // Do not include Bottom so scrolled content is drawn below system bars. - // Include Horizontal because some devices have camera cutouts on the side. - WindowInsetsSides.Top + WindowInsetsSides.Horizontal, - ), - ) { paddingValues -> - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - // Support edge-to-edge (required on Android 15) - // https://developer.android.com/develop/ui/compose/layouts/insets#inset-size - .padding( - // Draw to bottom edge. LazyColumn adds a Spacer for WindowInsets.systemBars. - // No bottom padding. - top = paddingValues.calculateTopPadding(), - start = 16.dp, - end = 16.dp, - ), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Button( + floatingActionButton = { + FloatingActionButton( onClick = onClickViewCart, - modifier = Modifier.padding(8.dp), - ) { - Text(text = "View Cart (${uiState.cartItemCount})") - } - LazyColumn( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(64.dp), + shape = MaterialTheme.shapes.extraLarge, ) { - items(items = uiState.fruitties, key = { it.id }) { item -> - FruittieItem( - item = item, - onAddToCart = viewModel::addItemToCart, - modifier = Modifier.fillMaxWidth(), - ) - } - // Support edge-to-edge (required on Android 15) - // https://developer.android.com/develop/ui/compose/layouts/insets#inset-size - item { - Spacer( - Modifier.windowInsetsBottomHeight( - WindowInsets.systemBars, - ), + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Filled.ShoppingCart, + contentDescription = null ) + Spacer(Modifier.width(8.dp)) + Text(text = stringResource(R.string.view_cart, uiState.cartItemCount)) } } + }, + ) { paddingValues -> + LazyColumn( + verticalArrangement = Arrangement.spacedBy(16.dp), +// contentPadding = paddingValues, + modifier = Modifier + .nestedScroll(topAppBarScrollBehavior.nestedScrollConnection) + .padding(paddingValues), + ) { + items(items = uiState.fruitties, key = { it.id }) { item -> + FruittieItem( + item = item, + onClick = onFruittieClick, + onAddToCart = viewModel::addItemToCart, + modifier = Modifier.fillMaxWidth(), + ) + } + item { + // Additional space because of FAB + Spacer(Modifier.height(50.dp)) + } } } } @@ -140,11 +129,16 @@ fun ListScreen(onClickViewCart: () -> Unit = {}) { @Composable fun FruittieItem( item: Fruittie, + onClick: (fruittie: Fruittie) -> Unit, onAddToCart: (fruittie: Fruittie) -> Unit, modifier: Modifier = Modifier, ) { Row( - modifier = modifier, + modifier = modifier + .clickable { + onClick(item) + } + .padding(16.dp), verticalAlignment = Alignment.CenterVertically, ) { Column( @@ -181,5 +175,6 @@ fun ItemPreview() { FruittieItem( Fruittie(name = "Fruit", fullName = "Fruitus Mangorus", calories = "240"), onAddToCart = {}, + onClick = {}, ) } diff --git a/Fruitties/androidApp/src/main/res/values/strings.xml b/Fruitties/androidApp/src/main/res/values/strings.xml index 7036394..1d433a3 100644 --- a/Fruitties/androidApp/src/main/res/values/strings.xml +++ b/Fruitties/androidApp/src/main/res/values/strings.xml @@ -16,5 +16,11 @@ --> "Frutties" + Cart Add + Loading… + View Cart (%1$d) + Navigate back + Add to cart + In cart: %1$d \ No newline at end of file diff --git a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/DataRepository.kt b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/DataRepository.kt index 9adc853..39b2049 100644 --- a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/DataRepository.kt +++ b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/DataRepository.kt @@ -23,6 +23,7 @@ import com.example.fruitties.network.FruittieApi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.launch @@ -48,6 +49,10 @@ class DataRepository( cartDataStore.add(fruittie) } + suspend fun removeFromCart(fruittie: Fruittie) { + cartDataStore.remove(fruittie) + } + fun getData(): Flow> { scope.launch { if (database.fruittieDao().count() < 1) { @@ -57,6 +62,16 @@ class DataRepository( return loadData() } + suspend fun getFruittie(id: Long): Fruittie? { + return database.fruittieDao().getFruittie(id) + } + + fun fruittieInCart(id: Long): Flow { + return cartDataStore.cart.map { cart -> + cart.items.find { it.id == id }?.count ?: 0 + } + } + fun loadData(): Flow> = database.fruittieDao().getAllAsFlow() suspend fun refreshData() { diff --git a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/database/FruittieDao.kt b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/database/FruittieDao.kt index beab284..2e15e79 100644 --- a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/database/FruittieDao.kt +++ b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/database/FruittieDao.kt @@ -37,6 +37,9 @@ interface FruittieDao { @Query("SELECT COUNT(*) as count FROM Fruittie") suspend fun count(): Int + @Query("SELECT * FROM Fruittie WHERE id = :id") + suspend fun getFruittie(id: Long): Fruittie? + @Query("SELECT * FROM Fruittie WHERE id in (:ids)") suspend fun loadAll(ids: List): List diff --git a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/CartViewModel.kt b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/CartViewModel.kt index 912cc56..ffedd7d 100644 --- a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/CartViewModel.kt +++ b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/CartViewModel.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch class CartViewModel( private val repository: DataRepository, @@ -53,6 +54,18 @@ class CartViewModel( initialValue = CartUiState(), ) + fun increaseCountClick(cartItem: CartItemDetails) { + viewModelScope.launch { + repository.addToCart(cartItem.fruittie) + } + } + + fun decreaseCountClick(cartItem: CartItemDetails) { + viewModelScope.launch { + repository.removeFromCart(cartItem.fruittie) + } + } + companion object { val Factory: ViewModelProvider.Factory = fruittiesViewModelFactory { CartViewModel(repository = it.dataRepository) diff --git a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/FruittieViewModel.kt b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/FruittieViewModel.kt new file mode 100644 index 0000000..871f94a --- /dev/null +++ b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/FruittieViewModel.kt @@ -0,0 +1,55 @@ +package com.example.fruitties.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.CreationExtras +import com.example.fruitties.DataRepository +import com.example.fruitties.model.Fruittie +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class FruittieViewModel( + private val fruittieId: Long, + private val repository: DataRepository, +) : ViewModel() { + + sealed interface State { + data object Loading : State + data class Content( + val inCart: Int, + val fruittie: Fruittie + ) : State + } + + val state = combine( + flow { emit(repository.getFruittie(fruittieId)) }.filterNotNull(), + repository.fruittieInCart(fruittieId) + ) { fruittie, inCart -> + State.Content(inCart, fruittie) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = State.Loading, + ) + + fun addToCard(fruittie: Fruittie) { + viewModelScope.launch { + repository.addToCart(fruittie) + } + } + + companion object { + val Factory = fruittiesViewModelFactory { + FruittieViewModel( + fruittieId = get(FRUITTIE_ID_KEY) ?: error("Expected fruittieId!"), + repository = it.dataRepository + ) + } + + val FRUITTIE_ID_KEY = CreationExtras.Key() + } +} \ No newline at end of file From 0b434895431ae6532a6454cfd7df2f05745a1593 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Mlynari=C4=8D?= Date: Thu, 26 Jun 2025 11:19:54 +0200 Subject: [PATCH 03/13] ios: Fix toolbar --- Fruitties/iosApp/iosApp/ui/CartView.swift | 1 + Fruitties/iosApp/iosApp/ui/ContentView.swift | 26 +++++++++++--------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/Fruitties/iosApp/iosApp/ui/CartView.swift b/Fruitties/iosApp/iosApp/ui/CartView.swift index 84ef813..b82d607 100644 --- a/Fruitties/iosApp/iosApp/ui/CartView.swift +++ b/Fruitties/iosApp/iosApp/ui/CartView.swift @@ -47,6 +47,7 @@ struct CartView : View { CartDetailsView(cartViewModel: cartViewModel) Spacer() } + .navigationTitle("Your Cart") } } } diff --git a/Fruitties/iosApp/iosApp/ui/ContentView.swift b/Fruitties/iosApp/iosApp/ui/ContentView.swift index 811e422..0fb971e 100644 --- a/Fruitties/iosApp/iosApp/ui/ContentView.swift +++ b/Fruitties/iosApp/iosApp/ui/ContentView.swift @@ -35,17 +35,6 @@ struct ContentView: View { ) NavigationStack { VStack { - Text("Fruitties").font(.largeTitle).fontWeight(.bold) - NavigationLink { - ViewModelStoreOwnerProvider { - CartView() - } - } label: { - Observing(mainViewModel.homeUiState) { homeUIState in - let total = homeUIState.cartItemCount - Text("View Cart (\(total))") - } - } Observing(mainViewModel.homeUiState) { homeUIState in ScrollView { LazyVStack { @@ -66,6 +55,21 @@ struct ContentView: View { } } } + .navigationTitle("Fruitties") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + NavigationLink { + ViewModelStoreOwnerProvider { + CartView() + } + } label: { + Observing(mainViewModel.homeUiState) { homeUIState in + let total = homeUIState.cartItemCount + Text("View Cart (\(total))") + } + } + } + } } } } From 68514937957782afa9a72d40057b396866bc82da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Mlynari=C4=8D?= Date: Thu, 26 Jun 2025 11:32:37 +0200 Subject: [PATCH 04/13] ios: design cart with +- actions --- Fruitties/iosApp/iosApp/ui/CartView.swift | 36 ++++++++++++++++++----- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/Fruitties/iosApp/iosApp/ui/CartView.swift b/Fruitties/iosApp/iosApp/ui/CartView.swift index b82d607..fd9ca6b 100644 --- a/Fruitties/iosApp/iosApp/ui/CartView.swift +++ b/Fruitties/iosApp/iosApp/ui/CartView.swift @@ -39,15 +39,18 @@ struct CartView : View { /// For more details, refer to: https://skie.touchlab.co/features/flows-in-swiftui Observing(cartViewModel.cartUiState) { cartUIState in VStack { - HStack { - let total = cartUIState.totalItemCount - Text("Cart has \(total) items").padding() - Spacer() - } CartDetailsView(cartViewModel: cartViewModel) Spacer() } - .navigationTitle("Your Cart") + .navigationTitle("Cart") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Observing(cartViewModel.cartUiState) { cartUIState in + let total = cartUIState.totalItemCount + Text("Cart has \(total) items") + } + } + } } } } @@ -63,9 +66,28 @@ struct CartDetailsView: View { ScrollView { LazyVStack { ForEach(cartUIState.cartDetails, id: \.fruittie.id) { item in - Text("\(item.fruittie.name): \(item.count)") + HStack { + Text("\(item.count)x \(item.fruittie.name)") + .frame(maxWidth: .infinity, alignment: .leading) + Spacer() + Button(action: { + self.cartViewModel.decreaseCountClick(cartItem: item) + }) { + Image(systemName: "minus.circle.fill") + .foregroundColor(.red) + } + + Button(action: { + self.cartViewModel.increaseCountClick(cartItem: item) + }) { + Image(systemName: "plus.circle.fill") + .foregroundColor(.green) + } + } + .padding() } } + .animation(.default, value: cartUIState.cartDetails) } } } From 2e349c8a5d4d3f474e6e458726f794e2cb582b28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Mlynari=C4=8D?= Date: Thu, 26 Jun 2025 11:59:45 +0200 Subject: [PATCH 05/13] Fix typo --- .../com/example/fruitties/android/ui/FruittieScreen.kt | 2 +- .../com/example/fruitties/viewmodel/FruittieViewModel.kt | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/FruittieScreen.kt b/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/FruittieScreen.kt index d9aaf20..01806ef 100644 --- a/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/FruittieScreen.kt +++ b/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/FruittieScreen.kt @@ -53,7 +53,7 @@ fun FruittieScreen( FruittieScreen( state = state, onNavBarBack = onNavBarBack, - addToCart = { viewModel.addToCard(it) } + addToCart = { viewModel.addToCart(it) } ) } diff --git a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/FruittieViewModel.kt b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/FruittieViewModel.kt index 871f94a..4ed4598 100644 --- a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/FruittieViewModel.kt +++ b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/FruittieViewModel.kt @@ -17,12 +17,12 @@ class FruittieViewModel( private val repository: DataRepository, ) : ViewModel() { - sealed interface State { - data object Loading : State + sealed class State { + data object Loading : State() data class Content( val inCart: Int, val fruittie: Fruittie - ) : State + ) : State() } val state = combine( @@ -36,7 +36,7 @@ class FruittieViewModel( initialValue = State.Loading, ) - fun addToCard(fruittie: Fruittie) { + fun addToCart(fruittie: Fruittie) { viewModelScope.launch { repository.addToCart(fruittie) } From ea1bde899c473a71f2d968d2cff2d207228a7dc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Mlynari=C4=8D?= Date: Thu, 26 Jun 2025 11:59:53 +0200 Subject: [PATCH 06/13] Design frutttie + list + cart screens Design list + cart screens --- Fruitties/iosApp/iosApp/ui/CartView.swift | 50 ++++++----- Fruitties/iosApp/iosApp/ui/ContentView.swift | 65 ++++++-------- .../iosApp/iosApp/ui/FruittieScreen.swift | 84 +++++++++++++++++++ 3 files changed, 138 insertions(+), 61 deletions(-) create mode 100644 Fruitties/iosApp/iosApp/ui/FruittieScreen.swift diff --git a/Fruitties/iosApp/iosApp/ui/CartView.swift b/Fruitties/iosApp/iosApp/ui/CartView.swift index fd9ca6b..121fda5 100644 --- a/Fruitties/iosApp/iosApp/ui/CartView.swift +++ b/Fruitties/iosApp/iosApp/ui/CartView.swift @@ -18,7 +18,7 @@ import Foundation import SwiftUI import shared -struct CartView : View { +struct CartView: View { /// Injects the `IOSViewModelStoreOwner` from the environment, which manages the lifecycle of `ViewModel` instances. @EnvironmentObject var viewModelStoreOwner: IOSViewModelStoreOwner @@ -63,32 +63,36 @@ struct CartDetailsView: View { /// This allows SwiftUI to react to changes in the cart's UI state. /// For more details, refer to: https://skie.touchlab.co/features/flows-in-swiftui Observing(self.cartViewModel.cartUiState) { cartUIState in - ScrollView { - LazyVStack { - ForEach(cartUIState.cartDetails, id: \.fruittie.id) { item in - HStack { - Text("\(item.count)x \(item.fruittie.name)") - .frame(maxWidth: .infinity, alignment: .leading) - Spacer() - Button(action: { - self.cartViewModel.decreaseCountClick(cartItem: item) - }) { - Image(systemName: "minus.circle.fill") - .foregroundColor(.red) - } - - Button(action: { - self.cartViewModel.increaseCountClick(cartItem: item) - }) { - Image(systemName: "plus.circle.fill") - .foregroundColor(.green) - } + List { + ForEach(cartUIState.cartDetails, id: \.fruittie.id) { item in + HStack { + Text("\(item.count)x \(item.fruittie.name)") + .frame(maxWidth: .infinity, alignment: .leading) + Spacer() + Button(action: { + self.cartViewModel.decreaseCountClick( + cartItem: item + ) + }) { + Image(systemName: "minus.circle.fill") + .foregroundColor(.red) } - .padding() + .buttonStyle(.plain) + + Button(action: { + self.cartViewModel.increaseCountClick( + cartItem: item + ) + }) { + Image(systemName: "plus.circle.fill") + .foregroundColor(.green) + } + .buttonStyle(.plain) } } - .animation(.default, value: cartUIState.cartDetails) } + .listStyle(.inset) + .animation(.default, value: cartUIState.cartDetails) } } } diff --git a/Fruitties/iosApp/iosApp/ui/ContentView.swift b/Fruitties/iosApp/iosApp/ui/ContentView.swift index 0fb971e..cdc6e28 100644 --- a/Fruitties/iosApp/iosApp/ui/ContentView.swift +++ b/Fruitties/iosApp/iosApp/ui/ContentView.swift @@ -34,23 +34,27 @@ struct ContentView: View { extras: creationExtras(appContainer: appContainer.value) ) NavigationStack { - VStack { - Observing(mainViewModel.homeUiState) { homeUIState in - ScrollView { - LazyVStack { - ForEach(homeUIState.fruitties, id: \.self) { - value in - FruittieView( - fruittie: value, - addToCart: { fruittie in - Task { - mainViewModel.addItemToCart( - fruittie: fruittie - ) - } - } - ) + Observing(mainViewModel.homeUiState) { homeUIState in + List { + ForEach(homeUIState.fruitties, id: \.self) { + value in + HStack { + NavigationLink { + ViewModelStoreOwnerProvider { + FruittieScreen(fruittie: value) + } + } label: { + FruittieView(fruittie: value) } + Spacer() + Button( + "Add", + action: { + mainViewModel.addItemToCart( + fruittie: value + ) + } + ).buttonStyle(.bordered) } } } @@ -76,28 +80,13 @@ struct ContentView: View { struct FruittieView: View { var fruittie: Fruittie - var addToCart: (Fruittie) -> Void var body: some View { - HStack(alignment: .firstTextBaseline) { - ZStack { - RoundedRectangle(cornerRadius: 15).fill( - Color(red: 0.8, green: 0.8, blue: 1.0) - ) - VStack { - Text("\(fruittie.name)") - .fontWeight(.bold) - .frame(maxWidth: .infinity, alignment: .leading) - Text("\(fruittie.fullName)") - .frame(maxWidth: .infinity, alignment: .leading) - }.padding() - Spacer() - Button( - action: { addToCart(fruittie) }, - label: { - Text("Add") - } - ).padding().frame(maxWidth: .infinity, alignment: .trailing) - }.padding([.leading, .trailing]) - } + VStack { + Text("\(fruittie.name)") + .fontWeight(.bold) + .frame(maxWidth: .infinity, alignment: .leading) + Text("\(fruittie.fullName)") + .frame(maxWidth: .infinity, alignment: .leading) + }.padding(.vertical, 5) } } diff --git a/Fruitties/iosApp/iosApp/ui/FruittieScreen.swift b/Fruitties/iosApp/iosApp/ui/FruittieScreen.swift new file mode 100644 index 0000000..4e9d0bb --- /dev/null +++ b/Fruitties/iosApp/iosApp/ui/FruittieScreen.swift @@ -0,0 +1,84 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation +import SwiftUI +import shared + +struct FruittieScreen: View { + @EnvironmentObject var viewModelStoreOwner: IOSViewModelStoreOwner + @EnvironmentObject var appContainer: ObservableValueWrapper + let fruittie: Fruittie + + var body: some View { + let fruittieViewModel: FruittieViewModel = + viewModelStoreOwner.viewModel( + factory: FruittieViewModel.companion.Factory, + extras: creationExtras( + appContainer: appContainer.value, + additional: { extras in + extras.set( + key: FruittieViewModel.companion.FRUITTIE_ID_KEY, + t: fruittie.id + ) + } + ) + ) + + Observing(fruittieViewModel.state) { uiState in + switch onEnum(of: uiState) { + case .loading: + VStack { + ProgressView() + Text("Loading fruittie details...") + } + .navigationTitle(fruittie.name) + + case .content(let content): + VStack { + Text(content.fruittie.fullName) + .font(.title2) + .padding(.bottom, 5) + Text("\(content.fruittie.calories) calories") + .font(.title3) + + Spacer() + Button(action: { + fruittieViewModel.addToCart(fruittie: content.fruittie) + }) { + HStack { + Image(systemName: "cart.fill") + Text("Add to cart") + } + .padding() + .frame(maxWidth: .infinity) + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(10) + } + .padding(.horizontal) + } + .navigationTitle(content.fruittie.name) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Text("In cart: \(content.inCart)") + } + } + } + + } + } +} From b5b9ee5e4fb6fc28e113114cd1bb0299a68fb933 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Mlynari=C4=8D?= Date: Thu, 26 Jun 2025 12:31:55 +0200 Subject: [PATCH 07/13] Revert "Merge branch 'main' into mlykotom/fruittie-screen-and-ios-designs" This reverts commit a2761d659776eb273484a96b793a9b2f7b421428, reversing changes made to ea1bde899c473a71f2d968d2cff2d207228a7dc2. --- .github/workflows/Fruitties.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/Fruitties.yaml b/.github/workflows/Fruitties.yaml index 9601be0..b652f1c 100644 --- a/.github/workflows/Fruitties.yaml +++ b/.github/workflows/Fruitties.yaml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@v4 - name: Validate Gradle Wrapper - uses: gradle/actions/wrapper-validation@v3 + uses: gradle/wrapper-validation-action@v3 - name: Set up JDK 17 uses: actions/setup-java@v4 From a1dfdf7079fe3881300b87f5c0b3338afa851ece Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Mlynari=C4=8D?= Date: Fri, 27 Jun 2025 12:29:21 +0200 Subject: [PATCH 08/13] Update Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/CartScreen.kt Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../main/java/com/example/fruitties/android/ui/CartScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/CartScreen.kt b/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/CartScreen.kt index f1e0be9..65c5e9e 100644 --- a/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/CartScreen.kt +++ b/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/CartScreen.kt @@ -156,7 +156,7 @@ fun CartItem( Spacer(Modifier.weight(1f)) FilledIconButton( onClick = { decreaseCountClick(cartItem) }, - colors = IconButtonDefaults.filledIconButtonColors(containerColor = Color.Red), + colors = IconButtonDefaults.filledIconButtonColors(containerColor = MaterialTheme.colorScheme.error), ) { Text( text = "-", From 3cafda533dfdf4cd08f4831c28630fa7a2eec621 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Mlynari=C4=8D?= Date: Mon, 30 Jun 2025 10:34:42 +0200 Subject: [PATCH 09/13] Add previews to CartScreen --- .../fruitties/android/ui/CartScreen.kt | 94 +++++++++++++++++-- 1 file changed, 87 insertions(+), 7 deletions(-) diff --git a/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/CartScreen.kt b/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/CartScreen.kt index 65c5e9e..6f8f19b 100644 --- a/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/CartScreen.kt +++ b/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/CartScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding @@ -38,7 +39,6 @@ import androidx.compose.material3.FilledIconButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonColors import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -53,17 +53,21 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.fruitties.android.MyApplicationTheme import com.example.fruitties.android.R import com.example.fruitties.android.di.App import com.example.fruitties.model.CartItemDetails +import com.example.fruitties.model.Fruittie +import com.example.fruitties.viewmodel.CartUiState import com.example.fruitties.viewmodel.CartViewModel import com.example.fruitties.viewmodel.creationExtras -@OptIn(ExperimentalMaterial3Api::class) @Composable fun CartScreen(onNavBarBack: () -> Unit) { + // Instantiate a ViewModel with a dependency on the AppContainer. // To make ViewModel compatible with KMP, the ViewModel factory must // create an instance without referencing the Android Application. @@ -78,6 +82,22 @@ fun CartScreen(onNavBarBack: () -> Unit) { val cartState by viewModel.cartUiState.collectAsState() + CartScreen( + onNavBarBack = onNavBarBack, + cartState = cartState, + increaseCountClick = viewModel::increaseCountClick, + decreaseCountClick = viewModel::decreaseCountClick, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CartScreen( + onNavBarBack: () -> Unit, + cartState: CartUiState, + decreaseCountClick: (CartItemDetails) -> Unit, + increaseCountClick: (CartItemDetails) -> Unit, +) { Scaffold( topBar = { CenterAlignedTopAppBar( @@ -109,14 +129,13 @@ fun CartScreen(onNavBarBack: () -> Unit) { ) { paddingValues -> Column( modifier = Modifier - // Support edge-to-edge (required on Android 15) - // https://developer.android.com/develop/ui/compose/layouts/insets#inset-size .padding(paddingValues) + .consumeWindowInsets(paddingValues) .padding(16.dp), ) { val cartItemCount = cartState.totalItemCount Text( - text = "Cart has $cartItemCount items", + text = stringResource(R.string.cart_has_items, cartItemCount), ) HorizontalDivider() LazyColumn( @@ -125,8 +144,8 @@ fun CartScreen(onNavBarBack: () -> Unit) { items(cartState.cartDetails) { cartItem -> CartItem( cartItem = cartItem, - decreaseCountClick = viewModel::decreaseCountClick, - increaseCountClick = viewModel::increaseCountClick, + decreaseCountClick = decreaseCountClick, + increaseCountClick = increaseCountClick, ) } item { @@ -146,8 +165,10 @@ fun CartItem( cartItem: CartItemDetails, increaseCountClick: (CartItemDetails) -> Unit, decreaseCountClick: (CartItemDetails) -> Unit, + modifier: Modifier = Modifier, ) { Row( + modifier = modifier, verticalAlignment = Alignment.CenterVertically, ) { Text(text = "${cartItem.count}x") @@ -176,3 +197,62 @@ fun CartItem( } } } + +@Preview +@Composable +private fun CartScreenPreview() { + MyApplicationTheme { + CartScreen( + onNavBarBack = {}, + cartState = CartUiState( + cartDetails = listOf( + CartItemDetails( + fruittie = Fruittie( + name = "Banana", + fullName = "Banana Banana", + calories = "100", + ), + count = 4, + ), + CartItemDetails( + fruittie = Fruittie( + name = "Orange", + fullName = "Orange Orange", + calories = "100", + ), + count = 1, + ), + CartItemDetails( + fruittie = Fruittie( + name = "Apple", + fullName = "Apple Apple", + calories = "100", + ), + count = 100, + ), + ) + ), + decreaseCountClick = {}, + increaseCountClick = {}, + ) + } +} + +@Preview +@Composable +private fun CartItemPreview() { + MyApplicationTheme { + CartItem( + cartItem = CartItemDetails( + fruittie = Fruittie( + name = "Banana", + fullName = "Banana Banana", + calories = "100", + ), + count = 4, + ), + increaseCountClick = {}, + decreaseCountClick = {} + ) + } +} \ No newline at end of file From 33aa359458dbd7de69c235b927ad0b757e5d0045 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Mlynari=C4=8D?= Date: Mon, 30 Jun 2025 10:34:51 +0200 Subject: [PATCH 10/13] TotalItemCounts is calculated from state --- .../com/example/fruitties/viewmodel/CartViewModel.kt | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/CartViewModel.kt b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/CartViewModel.kt index ffedd7d..88cb41b 100644 --- a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/CartViewModel.kt +++ b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/CartViewModel.kt @@ -44,10 +44,7 @@ class CartViewModel( val cartUiState: StateFlow = repository.cartDetails .map { details -> - CartUiState( - cartDetails = details, - totalItemCount = details.sumOf { it.count }, - ) + CartUiState(cartDetails = details) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(TIMEOUT_MILLIS), @@ -78,7 +75,8 @@ class CartViewModel( */ data class CartUiState( val cartDetails: List = listOf(), - val totalItemCount: Int = 0, -) +) { + val totalItemCount: Int = cartDetails.sumOf { it.count } +} private const val TIMEOUT_MILLIS = 5_000L From f802fed7cc09be981013c15f267c1e0252e5e06e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Mlynari=C4=8D?= Date: Mon, 30 Jun 2025 10:35:05 +0200 Subject: [PATCH 11/13] String res for cart has items --- Fruitties/androidApp/src/main/res/values/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/Fruitties/androidApp/src/main/res/values/strings.xml b/Fruitties/androidApp/src/main/res/values/strings.xml index 1d433a3..7f615a2 100644 --- a/Fruitties/androidApp/src/main/res/values/strings.xml +++ b/Fruitties/androidApp/src/main/res/values/strings.xml @@ -23,4 +23,5 @@ Navigate back Add to cart In cart: %1$d + Cart has %1$d items \ No newline at end of file From 7b61df5f066b0c0bd60c1b82945f869410cf56a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Mlynari=C4=8D?= Date: Mon, 30 Jun 2025 10:35:14 +0200 Subject: [PATCH 12/13] Handle paddings on list screen --- .../com/example/fruitties/android/ui/ListScreen.kt | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/ListScreen.kt b/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/ListScreen.kt index 9f5159b..f37085c 100644 --- a/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/ListScreen.kt +++ b/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/ListScreen.kt @@ -19,10 +19,11 @@ package com.example.fruitties.android.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn @@ -105,10 +106,11 @@ fun ListScreen( ) { paddingValues -> LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), -// contentPadding = paddingValues, + contentPadding = PaddingValues(bottom = 72.dp), modifier = Modifier .nestedScroll(topAppBarScrollBehavior.nestedScrollConnection) - .padding(paddingValues), + .padding(paddingValues) + .consumeWindowInsets(paddingValues), ) { items(items = uiState.fruitties, key = { it.id }) { item -> FruittieItem( @@ -118,10 +120,6 @@ fun ListScreen( modifier = Modifier.fillMaxWidth(), ) } - item { - // Additional space because of FAB - Spacer(Modifier.height(50.dp)) - } } } } From d107f5380f1c2c53b0275cbdd19042a882c8d957 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Mlynari=C4=8D?= Date: Thu, 3 Jul 2025 09:21:15 +0200 Subject: [PATCH 13/13] Format: Spotless --- .../example/fruitties/android/MainActivity.kt | 5 +++-- .../fruitties/android/ui/CartScreen.kt | 7 +++--- .../fruitties/android/ui/FruittieScreen.kt | 22 +++++++++---------- .../fruitties/android/ui/ListScreen.kt | 9 ++++---- .../com/example/fruitties/DataRepository.kt | 9 +++----- .../fruitties/viewmodel/CartViewModel.kt | 1 - .../fruitties/viewmodel/FruittieViewModel.kt | 10 ++++----- .../fruitties/viewmodel/MainViewModel.kt | 1 - .../fruitties/viewmodel/ViewModelFactory.kt | 8 +++---- .../di/viewmodel/IOSViewModelStoreOwner.kt | 2 +- 10 files changed, 33 insertions(+), 41 deletions(-) diff --git a/Fruitties/androidApp/src/main/java/com/example/fruitties/android/MainActivity.kt b/Fruitties/androidApp/src/main/java/com/example/fruitties/android/MainActivity.kt index 03737de..cd4c6e2 100644 --- a/Fruitties/androidApp/src/main/java/com/example/fruitties/android/MainActivity.kt +++ b/Fruitties/androidApp/src/main/java/com/example/fruitties/android/MainActivity.kt @@ -45,7 +45,9 @@ data object ListScreenKey : NavKey data object CartScreenKey : NavKey @Serializable -data class FruittieScreenKey(val id: Long) : NavKey +data class FruittieScreenKey( + val id: Long, +) : NavKey class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -106,4 +108,3 @@ fun NavApp() { }, ) } - diff --git a/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/CartScreen.kt b/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/CartScreen.kt index 6f8f19b..cf71e60 100644 --- a/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/CartScreen.kt +++ b/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/CartScreen.kt @@ -67,7 +67,6 @@ import com.example.fruitties.viewmodel.creationExtras @Composable fun CartScreen(onNavBarBack: () -> Unit) { - // Instantiate a ViewModel with a dependency on the AppContainer. // To make ViewModel compatible with KMP, the ViewModel factory must // create an instance without referencing the Android Application. @@ -230,7 +229,7 @@ private fun CartScreenPreview() { ), count = 100, ), - ) + ), ), decreaseCountClick = {}, increaseCountClick = {}, @@ -252,7 +251,7 @@ private fun CartItemPreview() { count = 4, ), increaseCountClick = {}, - decreaseCountClick = {} + decreaseCountClick = {}, ) } -} \ No newline at end of file +} diff --git a/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/FruittieScreen.kt b/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/FruittieScreen.kt index 01806ef..991827c 100644 --- a/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/FruittieScreen.kt +++ b/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/FruittieScreen.kt @@ -37,7 +37,7 @@ import com.example.fruitties.viewmodel.creationExtras @Composable fun FruittieScreen( fruittieId: Long, - onNavBarBack: () -> Unit + onNavBarBack: () -> Unit, ) { val app = LocalContext.current.applicationContext as App @@ -53,7 +53,7 @@ fun FruittieScreen( FruittieScreen( state = state, onNavBarBack = onNavBarBack, - addToCart = { viewModel.addToCart(it) } + addToCart = { viewModel.addToCart(it) }, ) } @@ -62,7 +62,7 @@ fun FruittieScreen( fun FruittieScreen( state: FruittieViewModel.State, addToCart: (Fruittie) -> Unit, - onNavBarBack: () -> Unit + onNavBarBack: () -> Unit, ) { Scaffold( topBar = { @@ -70,7 +70,7 @@ fun FruittieScreen( title = { Text( (state as? FruittieViewModel.State.Content)?.fruittie?.name - ?: stringResource(R.string.loading) + ?: stringResource(R.string.loading), ) }, actions = { @@ -94,29 +94,29 @@ fun FruittieScreen( FloatingActionButton( shape = MaterialTheme.shapes.extraLarge, - onClick = { addToCart(state.fruittie) } + onClick = { addToCart(state.fruittie) }, ) { Row( modifier = Modifier.padding( - horizontal = 16.dp + horizontal = 16.dp, ), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Icon( Icons.Filled.ShoppingCart, - contentDescription = null + contentDescription = null, ) Spacer(Modifier.width(8.dp)) Text(text = stringResource(R.string.add_to_cart)) } } - } + }, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .padding(it) - .fillMaxSize() + .fillMaxSize(), ) { when (state) { FruittieViewModel.State.Loading -> CircularProgressIndicator() @@ -127,4 +127,4 @@ fun FruittieScreen( } } } -} \ No newline at end of file +} diff --git a/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/ListScreen.kt b/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/ListScreen.kt index f37085c..7c4990e 100644 --- a/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/ListScreen.kt +++ b/Fruitties/androidApp/src/main/java/com/example/fruitties/android/ui/ListScreen.kt @@ -82,7 +82,7 @@ fun ListScreen( scrollBehavior = topAppBarScrollBehavior, title = { Text(text = stringResource(R.string.frutties)) - } + }, ) }, floatingActionButton = { @@ -92,11 +92,11 @@ fun ListScreen( ) { Row( modifier = Modifier.padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Icon( Icons.Filled.ShoppingCart, - contentDescription = null + contentDescription = null, ) Spacer(Modifier.width(8.dp)) Text(text = stringResource(R.string.view_cart, uiState.cartItemCount)) @@ -135,8 +135,7 @@ fun FruittieItem( modifier = modifier .clickable { onClick(item) - } - .padding(16.dp), + }.padding(16.dp), verticalAlignment = Alignment.CenterVertically, ) { Column( diff --git a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/DataRepository.kt b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/DataRepository.kt index 39b2049..abd9a5b 100644 --- a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/DataRepository.kt +++ b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/DataRepository.kt @@ -62,15 +62,12 @@ class DataRepository( return loadData() } - suspend fun getFruittie(id: Long): Fruittie? { - return database.fruittieDao().getFruittie(id) - } + suspend fun getFruittie(id: Long): Fruittie? = database.fruittieDao().getFruittie(id) - fun fruittieInCart(id: Long): Flow { - return cartDataStore.cart.map { cart -> + fun fruittieInCart(id: Long): Flow = + cartDataStore.cart.map { cart -> cart.items.find { it.id == id }?.count ?: 0 } - } fun loadData(): Flow> = database.fruittieDao().getAllAsFlow() diff --git a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/CartViewModel.kt b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/CartViewModel.kt index 88cb41b..b20446b 100644 --- a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/CartViewModel.kt +++ b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/CartViewModel.kt @@ -31,7 +31,6 @@ import kotlinx.coroutines.launch class CartViewModel( private val repository: DataRepository, ) : ViewModel() { - init { Logger.v { "CartViewModel created" } } diff --git a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/FruittieViewModel.kt b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/FruittieViewModel.kt index 4ed4598..6472bef 100644 --- a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/FruittieViewModel.kt +++ b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/FruittieViewModel.kt @@ -16,18 +16,18 @@ class FruittieViewModel( private val fruittieId: Long, private val repository: DataRepository, ) : ViewModel() { - sealed class State { data object Loading : State() + data class Content( val inCart: Int, - val fruittie: Fruittie + val fruittie: Fruittie, ) : State() } val state = combine( flow { emit(repository.getFruittie(fruittieId)) }.filterNotNull(), - repository.fruittieInCart(fruittieId) + repository.fruittieInCart(fruittieId), ) { fruittie, inCart -> State.Content(inCart, fruittie) }.stateIn( @@ -46,10 +46,10 @@ class FruittieViewModel( val Factory = fruittiesViewModelFactory { FruittieViewModel( fruittieId = get(FRUITTIE_ID_KEY) ?: error("Expected fruittieId!"), - repository = it.dataRepository + repository = it.dataRepository, ) } val FRUITTIE_ID_KEY = CreationExtras.Key() } -} \ No newline at end of file +} diff --git a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/MainViewModel.kt b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/MainViewModel.kt index 110d35b..c7ee926 100644 --- a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/MainViewModel.kt +++ b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/MainViewModel.kt @@ -31,7 +31,6 @@ import kotlinx.coroutines.launch class MainViewModel( private val repository: DataRepository, ) : ViewModel() { - init { Logger.v { "MainViewModel created" } } diff --git a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/ViewModelFactory.kt b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/ViewModelFactory.kt index 2668540..b05a9a5 100644 --- a/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/ViewModelFactory.kt +++ b/Fruitties/shared/src/commonMain/kotlin/com/example/fruitties/viewmodel/ViewModelFactory.kt @@ -16,19 +16,17 @@ fun creationExtras(appContainer: AppContainer): CreationExtras = fun creationExtras( appContainer: AppContainer, - additional: MutableCreationExtras.() -> Unit + additional: MutableCreationExtras.() -> Unit, ): CreationExtras = MutableCreationExtras().apply { set(APP_CONTAINER_KEY, appContainer) additional() } -inline fun fruittiesViewModelFactory( - crossinline initializer: CreationExtras.(AppContainer) -> T -) = +inline fun fruittiesViewModelFactory(crossinline initializer: CreationExtras.(AppContainer) -> T) = viewModelFactory { initializer { val appContainer = this[APP_CONTAINER_KEY] as AppContainer this.initializer(appContainer) } - } \ No newline at end of file + } diff --git a/Fruitties/shared/src/iosMain/kotlin/com/example/fruitties/di/viewmodel/IOSViewModelStoreOwner.kt b/Fruitties/shared/src/iosMain/kotlin/com/example/fruitties/di/viewmodel/IOSViewModelStoreOwner.kt index ba0fc57..8db4253 100644 --- a/Fruitties/shared/src/iosMain/kotlin/com/example/fruitties/di/viewmodel/IOSViewModelStoreOwner.kt +++ b/Fruitties/shared/src/iosMain/kotlin/com/example/fruitties/di/viewmodel/IOSViewModelStoreOwner.kt @@ -23,4 +23,4 @@ fun ViewModelStore.getViewModel( ?: error("modelClass isn't a ViewModel type") val provider = ViewModelProvider.create(this, factory, extras) return key?.let { provider[key, vmClass] } ?: provider[vmClass] -} \ No newline at end of file +}