From a3c54478ec83018514f30e970ea22a9667d177aa Mon Sep 17 00:00:00 2001 From: dadachi Date: Tue, 13 Oct 2026 10:39:44 +0900 Subject: [PATCH 1/6] Remove the no-op Data.updateRelationships Data.updateRelationships returned a copy with the same relationships (or the same instance), so getDatumWithRelationships() was the decoded datum and Shop/ItemTag.getData() was datum. The callers now read datum directly and the no-op is gone; nothing merges "included", as before. DataRelationshipsTest pinned the no-op itself (equal copy, same instance, getDatumWithRelationships == datum). Those cases tested the removed functions, so they go; the behavior they guarded (included resources are not merged, getData() is the decoded data, getShopName() does not come from included) stays covered by the remaining cases. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH --- .../ui/shop_detail/ShopDetailView.kt | 2 +- .../item_tag_list/ItemTagListViewModel.kt | 2 +- .../ui/shops/ShopListView.kt | 2 +- .../nativeapptemplatefree/model/Data.kt | 10 ----- .../nativeapptemplatefree/model/ItemTag.kt | 8 +--- .../nativeapptemplatefree/model/ItemTags.kt | 8 +--- .../nativeapptemplatefree/model/Shop.kt | 7 +-- .../nativeapptemplatefree/model/Shops.kt | 4 -- .../model/DataRelationshipsTest.kt | 45 ++++--------------- 9 files changed, 15 insertions(+), 73 deletions(-) diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_detail/ShopDetailView.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_detail/ShopDetailView.kt index e78059d..64177a2 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_detail/ShopDetailView.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_detail/ShopDetailView.kt @@ -137,7 +137,7 @@ private fun ShopDetailContentView( ) .padding(padding), ) { - val itemTags = uiState.itemTags.getDatumWithRelationships().toMutableList() + val itemTags = uiState.itemTags.datum.toMutableList() LazyColumn( Modifier.padding(24.dp), diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListViewModel.kt index d131334..3a92a98 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListViewModel.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListViewModel.kt @@ -108,7 +108,7 @@ class ItemTagListViewModel @Inject constructor( shopFlow, itemTagsFlow, ) { shop, itemTags -> - val newItems = itemTags.getDatumWithRelationships() + val newItems = itemTags.datum // A tag can come back on the next page too when the list shifted between the loads (e.g. a // tag was added). Keep its first occurrence so each id is listed once, in the loaded order. val allItems = (if (isReload) newItems else _uiState.value.itemTags + newItems).distinctBy { it.id } diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopListView.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopListView.kt index 79cdace..4b3906c 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopListView.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shops/ShopListView.kt @@ -177,7 +177,7 @@ private fun ShopListContentView( } items( - items = uiState.shops.getDatumWithRelationships(), + items = uiState.shops.datum, key = { it.id!! }, ) { shop -> ShopListCardView( diff --git a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Data.kt b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Data.kt index b5bd0c1..7681732 100644 --- a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Data.kt +++ b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Data.kt @@ -22,16 +22,6 @@ data class Data( fun getDescription(): String = attributes?.description ?: "" - fun updateRelationships(updatedRelations: List?): Data { - if (updatedRelations.isNullOrEmpty()) { - return this - } - - val updatedRelationships = relationships - - return this.copy(relationships = updatedRelationships) - } - fun getItemTagState(): ItemTagState? { return ItemTagState.fromParam(getState()) } diff --git a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ItemTag.kt b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ItemTag.kt index 3b24ca7..8de476b 100644 --- a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ItemTag.kt +++ b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ItemTag.kt @@ -13,13 +13,7 @@ data class ItemTag( val meta: Meta? = null, val included: List? = null, ) : Parcelable { - fun getData(): Data? { - if (included.isNullOrEmpty()) { - return datum - } - - return datum?.updateRelationships(included) - } + fun getData(): Data? = datum fun getId(): String = getData()?.id ?: "" diff --git a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ItemTags.kt b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ItemTags.kt index f986338..e8b171a 100644 --- a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ItemTags.kt +++ b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/ItemTags.kt @@ -6,7 +6,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** - * Model for list response of Bookmark, Content, Progressions + * The item tags of a list response (one page). */ @Serializable @Parcelize @@ -15,8 +15,4 @@ data class ItemTags( val datum: List = emptyList(), val included: List? = null, val meta: Meta? = null, -) : Parcelable { - fun getDatumWithRelationships(): List = datum.map { - it.updateRelationships(included) - } -} +) : Parcelable diff --git a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Shop.kt b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Shop.kt index 98e642c..fd11876 100644 --- a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Shop.kt +++ b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Shop.kt @@ -13,13 +13,8 @@ data class Shop( val meta: Meta? = null, val included: List? = null, ) : Parcelable { - fun getData(): Data? { - if (included.isNullOrEmpty()) { - return datum - } + fun getData(): Data? = datum - return datum?.updateRelationships(included) - } fun getName(): String = getData()?.getName() ?: "" fun getDescription(): String = getData()?.getDescription() ?: "" diff --git a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Shops.kt b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Shops.kt index f8ebfbe..ef2355d 100644 --- a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Shops.kt +++ b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Shops.kt @@ -16,8 +16,4 @@ data class Shops( fun getCreatedShopsCount(): Int = meta?.createdShopsCount ?: 0 fun getLimitCount(): Int = meta?.limitCount ?: 0 - - fun getDatumWithRelationships(): List = datum.map { - it.updateRelationships(included) - } } diff --git a/model/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/DataRelationshipsTest.kt b/model/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/DataRelationshipsTest.kt index 058ebce..554a917 100644 --- a/model/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/DataRelationshipsTest.kt +++ b/model/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/DataRelationshipsTest.kt @@ -3,15 +3,13 @@ package com.nativeapptemplate.nativeapptemplatefree.model import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals import org.junit.Assert.assertNull -import org.junit.Assert.assertSame import org.junit.Test /** - * Characterizes `Data.updateRelationships` and the `getDatumWithRelationships()` / `getData()` - * callers as they behave today: the resources in "included" are NOT merged into anything. Each - * resource keeps the relationship references it was decoded with (`{"id", "type"}` only), so the - * calls return values equal to the decoded `data`. These tests pin that down so a refactoring - * (removing the no-op, or making it merge "included") is a deliberate, visible change. + * The app does not merge the resources in a response's "included" into anything: each resource + * keeps the relationship references it was decoded with (`{"id", "type"}` only), and the list and + * detail models hand out the decoded `data` as is. (A no-op `Data.updateRelationships` and its + * `getDatumWithRelationships()` callers used to suggest otherwise; they were removed.) */ class DataRelationshipsTest { // NetModule's networkJson settings for decoding. @@ -38,41 +36,14 @@ class DataRelationshipsTest { """ @Test - fun updateRelationships_withIncludedResources_returnsAnEqualCopy() { + fun itemTags_decodedWithIncludedResources_keepOnlyTheRelationshipReference() { val itemTags = json.decodeFromString(itemTagsResponse) val itemTag = itemTags.datum.single() - val updated = itemTag.updateRelationships(itemTags.included) - - assertEquals(itemTag, updated) // The shop reference keeps only what "relationships" carried: the included name is not merged in. - assertEquals("shop-1", updated.relationships?.shop?.datum?.id) - assertNull(updated.relationships?.shop?.datum?.attributes) - } - - @Test - fun updateRelationships_withoutIncludedResources_returnsTheSameInstance() { - val itemTag = json.decodeFromString(itemTagsResponse).datum.single() - - assertSame(itemTag, itemTag.updateRelationships(null)) - assertSame(itemTag, itemTag.updateRelationships(emptyList())) - } - - @Test - fun itemTags_getDatumWithRelationships_isTheDecodedData() { - val itemTags = json.decodeFromString(itemTagsResponse) - - assertEquals(itemTags.datum, itemTags.getDatumWithRelationships()) - } - - @Test - fun shops_getDatumWithRelationships_isTheDecodedData() { - val shops = Shops( - datum = listOf(Data(id = "shop-1", type = "shop", attributes = Attributes(name = "Fox Plaza"), relationships = Relationships(shops = Shops()))), - included = listOf(Data(id = "item-tag-1", type = "item_tag", attributes = Attributes(name = "A001"))), - ) - - assertEquals(shops.datum, shops.getDatumWithRelationships()) + assertEquals("shop-1", itemTag.relationships?.shop?.datum?.id) + assertNull(itemTag.relationships?.shop?.datum?.attributes) + assertEquals("Fox Plaza", itemTags.included?.single()?.getName()) } @Test From c0a415247f921576de5900098ac3d9afcb4361c9 Mon Sep 17 00:00:00 2001 From: dadachi Date: Tue, 13 Oct 2026 10:41:46 +0900 Subject: [PATCH 2/6] Drop the unused reveal state of the swipe rows Shop Detail and the item tag list wrote Data.isOptionsRevealed into a toMutableList() copy built during composition. Nothing observed that copy and the next composition rebuilt it, so the writes were lost and isRevealed was always the decoded default, false (the API never sends the field). Whether a row is open has always lived in the row's own Animatable. SwipeableItemWithActions loses isRevealed/onExpanded/onCollapsed (its LaunchedEffect keeps animating to closed when the actions width changes, as it did with isRevealed = false), the screens read the list from the UI state directly, and Data loses isOptionsRevealed. SwipeableItemWithActionsTest characterizes the row (swipe left opens it and it stays open across a parent recomposition; swipe right closes it). It passed against the old API (with isRevealed = false, as the screens passed) before the change and passes after; each case went red when the matching drag-end branch was broken. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH --- .../ui/common/SwipeableItemWithActions.kt | 15 +--- .../ui/shop_detail/ShopDetailView.kt | 17 +--- .../item_tag_list/ItemTagListView.kt | 14 +--- .../ui/common/SwipeableItemWithActionsTest.kt | 83 +++++++++++++++++++ .../nativeapptemplatefree/model/Data.kt | 1 - 5 files changed, 94 insertions(+), 36 deletions(-) create mode 100644 app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/SwipeableItemWithActionsTest.kt diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/SwipeableItemWithActions.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/SwipeableItemWithActions.kt index 088ce96..f28ec82 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/SwipeableItemWithActions.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/SwipeableItemWithActions.kt @@ -27,13 +27,11 @@ import kotlinx.coroutines.launch import kotlin.math.roundToInt // https://github.com/philipplackner/ComposeSwipeToReveal +/** A row that reveals [actions] when swiped left. The row itself remembers whether it is open. */ @Composable fun SwipeableItemWithActions( - isRevealed: Boolean, actions: @Composable RowScope.() -> Unit, modifier: Modifier = Modifier, - onExpanded: () -> Unit = {}, - onCollapsed: () -> Unit = {}, content: @Composable () -> Unit, ) { var contextMenuWidth by remember { @@ -44,12 +42,9 @@ fun SwipeableItemWithActions( } val scope = rememberCoroutineScope() - LaunchedEffect(key1 = isRevealed, contextMenuWidth) { - if (isRevealed) { - offset.animateTo(-contextMenuWidth) - } else { - offset.animateTo(0f) - } + // A new actions width (e.g. on the first layout) starts the row closed. + LaunchedEffect(contextMenuWidth) { + offset.animateTo(0f) } Box( @@ -85,14 +80,12 @@ fun SwipeableItemWithActions( offset.value <= -contextMenuWidth / 2f -> { scope.launch { offset.animateTo(-contextMenuWidth) - onExpanded() } } else -> { scope.launch { offset.animateTo(0f) - onCollapsed() } } } diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_detail/ShopDetailView.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_detail/ShopDetailView.kt index 64177a2..b0bc22c 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_detail/ShopDetailView.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_detail/ShopDetailView.kt @@ -7,7 +7,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Settings @@ -137,8 +137,6 @@ private fun ShopDetailContentView( ) .padding(padding), ) { - val itemTags = uiState.itemTags.datum.toMutableList() - LazyColumn( Modifier.padding(24.dp), ) { @@ -151,19 +149,12 @@ private fun ShopDetailContentView( .padding(start = 16.dp, bottom = 8.dp), ) } - itemsIndexed( - items = itemTags, - ) { index, itemTag -> + items( + items = uiState.itemTags.datum, + ) { itemTag -> val itemTagState = itemTag.getItemTagState() SwipeableItemWithActions( - isRevealed = itemTag.isOptionsRevealed, - onExpanded = { - itemTags[index] = itemTag.copy(isOptionsRevealed = true) - }, - onCollapsed = { - itemTags[index] = itemTag.copy(isOptionsRevealed = false) - }, actions = { if (itemTagState == Idled) { ActionText( diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListView.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListView.kt index 77b7954..f864368 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListView.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_list/ItemTagListView.kt @@ -138,12 +138,11 @@ private fun ItemTagListContentView( onBackClick: () -> Unit, ) { val isEmpty: Boolean by viewModel.isEmpty.collectAsStateWithLifecycle() - val itemTags = uiState.itemTags.toMutableList() val listState = rememberLazyListState() val prefetchDistance = 3 // derivedStateOf only tracks State reads, so read the latest list state through State objects; - // capturing uiState/itemTags directly would freeze them at their first-composition values. + // capturing uiState directly would freeze them at their first-composition values. val latestUiState by rememberUpdatedState(uiState) val shouldLoadMore by remember { derivedStateOf { @@ -208,19 +207,12 @@ private fun ItemTagListContentView( ) } itemsIndexed( - items = itemTags, + items = uiState.itemTags, // ItemTagListViewModel lists each id once, so the id is a unique key. The API always // sends an id; the index is only a fallback. key = { index, itemTag -> itemTag.id ?: index }, - ) { index, itemTag -> + ) { _, itemTag -> SwipeableItemWithActions( - isRevealed = itemTag.isOptionsRevealed, - onExpanded = { - itemTags[index] = itemTag.copy(isOptionsRevealed = true) - }, - onCollapsed = { - itemTags[index] = itemTag.copy(isOptionsRevealed = false) - }, actions = { ActionIcon( onClick = { diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/SwipeableItemWithActionsTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/SwipeableItemWithActionsTest.kt new file mode 100644 index 0000000..c57a6e0 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/SwipeableItemWithActionsTest.kt @@ -0,0 +1,83 @@ +package com.nativeapptemplate.nativeapptemplatefree.ui.common + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertLeftPositionInRootIsEqualTo +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeLeft +import androidx.compose.ui.test.swipeRight +import androidx.compose.ui.unit.dp +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * The swipe-to-reveal rows of the shop detail and item tag list screens: the row itself holds + * whether its actions are revealed, so a swiped row stays open while the screen recomposes (e.g. + * the list reloads) and closes when swiped back. + */ +@RunWith(RobolectricTestRunner::class) +class SwipeableItemWithActionsTest { + @get:Rule + val composeTestRule = createComposeRule() + + private var recompositions by mutableIntStateOf(0) + + private fun setContent() { + composeTestRule.setContent { + Column { + // Read here so changing it recomposes the parent of the row, as a list update does. + Text("$recompositions") + SwipeableItemWithActions( + actions = { Box(Modifier.width(ACTIONS_WIDTH).height(48.dp)) }, + ) { + Box(Modifier.fillMaxWidth().height(48.dp).testTag(ROW)) + } + } + } + } + + @Test + fun swipeLeft_revealsTheActions_andTheRowStaysOpenAcrossARecomposition() { + setContent() + + composeTestRule.onNodeWithTag(ROW).performTouchInput { swipeLeft() } + composeTestRule.waitForIdle() + // Fully open = the row moved left by the width of the actions. + composeTestRule.onNodeWithTag(ROW).assertLeftPositionInRootIsEqualTo(-ACTIONS_WIDTH) + + recompositions++ + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(ROW).assertLeftPositionInRootIsEqualTo(-ACTIONS_WIDTH) + } + + @Test + fun swipeRight_onAnOpenRow_closesIt() { + setContent() + composeTestRule.onNodeWithTag(ROW).performTouchInput { swipeLeft() } + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(ROW).performTouchInput { swipeRight() } + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(ROW).assertLeftPositionInRootIsEqualTo(0.dp) + } + + private companion object { + const val ROW = "row" + val ACTIONS_WIDTH = 96.dp + } +} diff --git a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Data.kt b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Data.kt index 7681732..3a81cbc 100644 --- a/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Data.kt +++ b/model/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/model/Data.kt @@ -12,7 +12,6 @@ data class Data( val attributes: Attributes? = null, val relationships: Relationships? = null, val meta: Meta? = null, - val isOptionsRevealed: Boolean = false, ) : Parcelable { fun getName(): String = attributes?.name ?: "" From 702af318ccad436863bb0fbd53a415a27d97a468 Mon Sep 17 00:00:00 2001 From: dadachi Date: Tue, 13 Oct 2026 10:42:19 +0900 Subject: [PATCH 3/6] Trim NativeAppTemplateAppState - Drop isLoggedIn, shouldUpdateApp, shouldUpdatePrivacy and shouldUpdateTerms: nothing reads them (routing uses authDestination). - shouldShowBottomBar repeated authDestination's combine of the same four flows; it is now authDestination == SHOPS, which is the same condition (signed in, no app/privacy/terms update pending) and the same initial false. - The flag StateFlows share one stateIn helper. NativeAppTemplateAppStateTest (unchanged) covers shouldShowBottomBar, authDestination and the flags. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH --- .../ui/app_root/NativeAppTemplateAppState.kt | 81 +++---------------- 1 file changed, 12 insertions(+), 69 deletions(-) diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/NativeAppTemplateAppState.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/NativeAppTemplateAppState.kt index b640839..2fcc11f 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/NativeAppTemplateAppState.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/NativeAppTemplateAppState.kt @@ -21,6 +21,7 @@ import com.nativeapptemplate.nativeapptemplatefree.ui.settings.navigation.naviga import com.nativeapptemplate.nativeapptemplatefree.ui.shops.navigation.navigateToShopList import com.nativeapptemplate.nativeapptemplatefree.utils.NetworkMonitor import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -60,24 +61,6 @@ class NativeAppTemplateAppState( @Composable get() = navController .currentBackStackEntryAsState().value?.destination - val shouldShowBottomBar: StateFlow = combine( - loginRepository.isLoggedIn(), - loginRepository.shouldUpdateApp(), - loginRepository.shouldUpdatePrivacy(), - loginRepository.shouldUpdateTerms(), - ) { - theIsLoggedIn, - theShouldUpdateApp, - theShouldUpdatePrivacy, - theShouldUpdateTerms, - -> - theIsLoggedIn && !theShouldUpdateApp && !theShouldUpdatePrivacy && !theShouldUpdateTerms - }.stateIn( - scope = coroutineScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = false, - ) - /** * Where the app should route to. Null until the signed-in state has loaded, so the app does not * route to onboarding on the initial `false` values before DataStore emits. @@ -95,62 +78,22 @@ class NativeAppTemplateAppState( initialValue = null, ) - val isLoggedIn = loginRepository.isLoggedIn() - .stateIn( - scope = coroutineScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = false, - ) + /** The tabs show only on the signed-in screens, not while a required update is pending. */ + val shouldShowBottomBar = authDestination.map { it == AuthDestination.SHOPS }.asState() - val shouldUpdateApp = loginRepository.shouldUpdateApp() - .stateIn( - scope = coroutineScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = false, - ) + val isEmailUpdated = loginRepository.isEmailUpdated().asState() - val shouldUpdatePrivacy = loginRepository.shouldUpdatePrivacy() - .stateIn( - scope = coroutineScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = false, - ) - - val shouldUpdateTerms = loginRepository.shouldUpdateTerms() - .stateIn( - scope = coroutineScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = false, - ) + val isMyAccountDeleted = loginRepository.isMyAccountDeleted().asState() - val isEmailUpdated = loginRepository.isEmailUpdated() - .stateIn( - scope = coroutineScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = false, - ) - - val isMyAccountDeleted = loginRepository.isMyAccountDeleted() - .stateIn( - scope = coroutineScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = false, - ) + val isShopDeleted = loginRepository.isShopDeleted().asState() - val isShopDeleted = loginRepository.isShopDeleted() - .stateIn( - scope = coroutineScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = false, - ) + val isOffline = networkMonitor.isOnline.map(Boolean::not).asState() - val isOffline = networkMonitor.isOnline - .map(Boolean::not) - .stateIn( - scope = coroutineScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = false, - ) + private fun Flow.asState(): StateFlow = stateIn( + scope = coroutineScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = false, + ) /** * Map of top level destinations to be used in the TopBar, BottomBar and NavRail. The key is the From 71f2c0b39d7bda52757c0417e9ea458e5b0ba307 Mon Sep 17 00:00:00 2001 From: dadachi Date: Tue, 13 Oct 2026 10:43:25 +0900 Subject: [PATCH 4/6] Share the API request boilerplate of the repositories Every pass-through repository method built the same flow by hand: read the account id, call the API, emitApiResponse (or emitApiResponse { true }), flowOn(ioDispatcher). apiRequest/apiAction (network/ApiResponseExtensions.kt) and NativeAppTemplatePreferencesDataSource.currentAccountId() now hold that once; each method only names its API call. The requests, the dispatcher and the error mapping are unchanged (RepositoryRequestsTest covers every endpoint; no test changed). Same helpers as NativeAppTemplate-Android #112. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH --- .../data/item_tag/ItemTagRepositoryImpl.kt | 63 ++++++++----------- .../login/AccountPasswordRepositoryImpl.kt | 14 ++--- .../data/login/LoginRepositoryImpl.kt | 37 +++++------ .../data/login/SignUpRepositoryImpl.kt | 48 ++++++-------- .../data/shop/ShopRepositoryImpl.kt | 49 +++++++-------- .../NativeAppTemplatePreferencesDataSource.kt | 4 ++ .../network/ApiResponseExtensions.kt | 19 ++++++ 7 files changed, 112 insertions(+), 122 deletions(-) diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/item_tag/ItemTagRepositoryImpl.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/item_tag/ItemTagRepositoryImpl.kt index 8fbc5c3..3dade16 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/item_tag/ItemTagRepositoryImpl.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/item_tag/ItemTagRepositoryImpl.kt @@ -4,11 +4,9 @@ import com.nativeapptemplate.nativeapptemplatefree.datastore.NativeAppTemplatePr import com.nativeapptemplate.nativeapptemplatefree.model.* import com.nativeapptemplate.nativeapptemplatefree.network.Dispatcher import com.nativeapptemplate.nativeapptemplatefree.network.NativeAppTemplateDispatchers -import com.nativeapptemplate.nativeapptemplatefree.network.emitApiResponse +import com.nativeapptemplate.nativeapptemplatefree.network.apiAction +import com.nativeapptemplate.nativeapptemplatefree.network.apiRequest import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOn import javax.inject.Inject class ItemTagRepositoryImpl @Inject constructor( @@ -20,67 +18,60 @@ class ItemTagRepositoryImpl @Inject constructor( override fun getItemTags( shopId: String, page: Int?, - ) = flow { - val response = api.getItemTags( - mtcPreferencesDataSource.userData.first().accountId, + ) = apiRequest(ioDispatcher) { + api.getItemTags( + mtcPreferencesDataSource.currentAccountId(), shopId, page, ) - emitApiResponse(response) - }.flowOn(ioDispatcher) + } override fun getItemTag( id: String, - ) = flow { - val response = api.getItemTag( - mtcPreferencesDataSource.userData.first().accountId, + ) = apiRequest(ioDispatcher) { + api.getItemTag( + mtcPreferencesDataSource.currentAccountId(), id, ) - emitApiResponse(response) - }.flowOn(ioDispatcher) + } override fun createItemTag( shopId: String, itemTagBody: ItemTagBody, - ) = flow { - val response = api.createItemTag( - mtcPreferencesDataSource.userData.first().accountId, + ) = apiRequest(ioDispatcher) { + api.createItemTag( + mtcPreferencesDataSource.currentAccountId(), shopId, itemTagBody, ) - emitApiResponse(response) - }.flowOn(ioDispatcher) + } override fun updateItemTag( id: String, itemTagBody: ItemTagBody, - ) = flow { - val response = api.updateItemTag( - mtcPreferencesDataSource.userData.first().accountId, + ) = apiRequest(ioDispatcher) { + api.updateItemTag( + mtcPreferencesDataSource.currentAccountId(), id, itemTagBody, ) - emitApiResponse(response) - }.flowOn(ioDispatcher) + } override fun deleteItemTag( id: String, - ) = flow { - val response = api.deleteItemTag(mtcPreferencesDataSource.userData.first().accountId, id) - emitApiResponse(response) { true } - }.flowOn(ioDispatcher) + ) = apiAction(ioDispatcher) { + api.deleteItemTag(mtcPreferencesDataSource.currentAccountId(), id) + } override fun completeItemTag( id: String, - ) = flow { - val response = api.completeItemTag(mtcPreferencesDataSource.userData.first().accountId, id) - emitApiResponse(response) - }.flowOn(ioDispatcher) + ) = apiRequest(ioDispatcher) { + api.completeItemTag(mtcPreferencesDataSource.currentAccountId(), id) + } override fun idleItemTag( id: String, - ) = flow { - val response = api.idleItemTag(mtcPreferencesDataSource.userData.first().accountId, id) - emitApiResponse(response) - }.flowOn(ioDispatcher) + ) = apiRequest(ioDispatcher) { + api.idleItemTag(mtcPreferencesDataSource.currentAccountId(), id) + } } diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/AccountPasswordRepositoryImpl.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/AccountPasswordRepositoryImpl.kt index 953e25e..2670fa6 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/AccountPasswordRepositoryImpl.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/AccountPasswordRepositoryImpl.kt @@ -4,11 +4,8 @@ import com.nativeapptemplate.nativeapptemplatefree.datastore.NativeAppTemplatePr import com.nativeapptemplate.nativeapptemplatefree.model.* import com.nativeapptemplate.nativeapptemplatefree.network.Dispatcher import com.nativeapptemplate.nativeapptemplatefree.network.NativeAppTemplateDispatchers -import com.nativeapptemplate.nativeapptemplatefree.network.emitApiResponse +import com.nativeapptemplate.nativeapptemplatefree.network.apiAction import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOn import javax.inject.Inject class AccountPasswordRepositoryImpl @Inject constructor( @@ -18,11 +15,10 @@ class AccountPasswordRepositoryImpl @Inject constructor( ) : AccountPasswordRepository { override fun updateAccountPassword( updatePasswordBody: UpdatePasswordBody, - ) = flow { - val response = api.updateAccountPassword( - natPreferencesDataSource.userData.first().accountId, + ) = apiAction(ioDispatcher) { + api.updateAccountPassword( + natPreferencesDataSource.currentAccountId(), updatePasswordBody, ) - emitApiResponse(response) { true } - }.flowOn(ioDispatcher) + } } diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepositoryImpl.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepositoryImpl.kt index 233b787..8dba178 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepositoryImpl.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/LoginRepositoryImpl.kt @@ -8,14 +8,14 @@ import com.nativeapptemplate.nativeapptemplatefree.model.LoggedInShopkeeper import com.nativeapptemplate.nativeapptemplatefree.model.Login import com.nativeapptemplate.nativeapptemplatefree.network.Dispatcher import com.nativeapptemplate.nativeapptemplatefree.network.NativeAppTemplateDispatchers -import com.nativeapptemplate.nativeapptemplatefree.network.emitApiResponse +import com.nativeapptemplate.nativeapptemplatefree.network.apiAction +import com.nativeapptemplate.nativeapptemplatefree.network.apiRequest import com.nativeapptemplate.nativeapptemplatefree.network.throwApiError import com.skydoves.sandwich.message import com.skydoves.sandwich.suspendOnFailure import com.skydoves.sandwich.suspendOnSuccess import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map @@ -33,10 +33,8 @@ class LoginRepositoryImpl @Inject constructor( override fun login( login: Login, - ) = flow { - val response = api.login(login) - emitApiResponse(response) - }.map { it.requireCompleteSession() } + ) = apiRequest(ioDispatcher) { api.login(login) } + .map { it.requireCompleteSession() } .flowOn(ioDispatcher) override val userData: Flow = @@ -54,26 +52,23 @@ class LoginRepositoryImpl @Inject constructor( } }.flowOn(ioDispatcher) - override fun getPermissions() = flow { - val response = api.getPermissions( - userData.first().accountId, + override fun getPermissions() = apiRequest(ioDispatcher) { + api.getPermissions( + natPreferencesDataSource.currentAccountId(), ) - emitApiResponse(response) - }.flowOn(ioDispatcher) + } - override fun updateConfirmedPrivacyVersion() = flow { - val response = api.updateConfirmedPrivacyVersion( - natPreferencesDataSource.userData.first().accountId, + override fun updateConfirmedPrivacyVersion() = apiAction(ioDispatcher) { + api.updateConfirmedPrivacyVersion( + natPreferencesDataSource.currentAccountId(), ) - emitApiResponse(response) { true } - }.flowOn(ioDispatcher) + } - override fun updateConfirmedTermsVersion() = flow { - val response = api.updateConfirmedTermsVersion( - natPreferencesDataSource.userData.first().accountId, + override fun updateConfirmedTermsVersion() = apiAction(ioDispatcher) { + api.updateConfirmedTermsVersion( + natPreferencesDataSource.currentAccountId(), ) - emitApiResponse(response) { true } - }.flowOn(ioDispatcher) + } override suspend fun setAccountId(accountId: String) { natPreferencesDataSource.setAccountId(accountId) diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/SignUpRepositoryImpl.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/SignUpRepositoryImpl.kt index 3bfa337..7de8dbb 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/SignUpRepositoryImpl.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/SignUpRepositoryImpl.kt @@ -5,13 +5,11 @@ import com.nativeapptemplate.nativeapptemplatefree.model.SendResetPassword import com.nativeapptemplate.nativeapptemplatefree.model.ShopkeeperLocaleUpdate import com.nativeapptemplate.nativeapptemplatefree.model.SignUp import com.nativeapptemplate.nativeapptemplatefree.model.SignUpForUpdate -import com.nativeapptemplate.nativeapptemplatefree.model.Status import com.nativeapptemplate.nativeapptemplatefree.network.Dispatcher import com.nativeapptemplate.nativeapptemplatefree.network.NativeAppTemplateDispatchers -import com.nativeapptemplate.nativeapptemplatefree.network.emitApiResponse +import com.nativeapptemplate.nativeapptemplatefree.network.apiAction +import com.nativeapptemplate.nativeapptemplatefree.network.apiRequest import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOn import javax.inject.Inject class SignUpRepositoryImpl @Inject constructor( @@ -20,41 +18,35 @@ class SignUpRepositoryImpl @Inject constructor( ) : SignUpRepository { override fun signUp( signUp: SignUp, - ) = flow { - val response = api.signUp(signUp) - emitApiResponse(response) - }.flowOn(ioDispatcher) + ) = apiRequest(ioDispatcher) { + api.signUp(signUp) + } override fun updateAccount( signUpForUpdate: SignUpForUpdate, - ) = flow { - val response = api.updateAccount(signUpForUpdate) - emitApiResponse(response) - }.flowOn(ioDispatcher) + ) = apiRequest(ioDispatcher) { + api.updateAccount(signUpForUpdate) + } override fun updateLocale( locale: String, - ) = flow { - val response = api.updateLocale(ShopkeeperLocaleUpdate(locale = locale)) - emitApiResponse(response) - }.flowOn(ioDispatcher) + ) = apiRequest(ioDispatcher) { + api.updateLocale(ShopkeeperLocaleUpdate(locale = locale)) + } - override fun deleteAccount() = flow { - val response = api.deleteAccount() - emitApiResponse(response) { true } - }.flowOn(ioDispatcher) + override fun deleteAccount() = apiAction(ioDispatcher) { + api.deleteAccount() + } override fun sendResetPasswordInstruction( sendResetPassword: SendResetPassword, - ) = flow { - val response = api.sendResetPasswordInstruction(sendResetPassword) - emitApiResponse(response) { true } - }.flowOn(ioDispatcher) + ) = apiAction(ioDispatcher) { + api.sendResetPasswordInstruction(sendResetPassword) + } override fun sendConfirmationInstruction( sendConfirmation: SendConfirmation, - ) = flow { - val response = api.sendConfirmationInstruction(sendConfirmation) - emitApiResponse(response) { true } - }.flowOn(ioDispatcher) + ) = apiAction(ioDispatcher) { + api.sendConfirmationInstruction(sendConfirmation) + } } diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/shop/ShopRepositoryImpl.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/shop/ShopRepositoryImpl.kt index 29b34ab..1e36bb9 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/shop/ShopRepositoryImpl.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/shop/ShopRepositoryImpl.kt @@ -4,11 +4,9 @@ import com.nativeapptemplate.nativeapptemplatefree.datastore.NativeAppTemplatePr import com.nativeapptemplate.nativeapptemplatefree.model.* import com.nativeapptemplate.nativeapptemplatefree.network.Dispatcher import com.nativeapptemplate.nativeapptemplatefree.network.NativeAppTemplateDispatchers -import com.nativeapptemplate.nativeapptemplatefree.network.emitApiResponse +import com.nativeapptemplate.nativeapptemplatefree.network.apiAction +import com.nativeapptemplate.nativeapptemplatefree.network.apiRequest import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOn import javax.inject.Inject class ShopRepositoryImpl @Inject constructor( @@ -17,49 +15,44 @@ class ShopRepositoryImpl @Inject constructor( @param:Dispatcher(NativeAppTemplateDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, ) : ShopRepository { - override fun getShops() = flow { - val response = api.getShops( - natPreferencesDataSource.userData.first().accountId, + override fun getShops() = apiRequest(ioDispatcher) { + api.getShops( + natPreferencesDataSource.currentAccountId(), ) - emitApiResponse(response) - }.flowOn(ioDispatcher) + } override fun getShop( id: String, - ) = flow { - val response = api.getShop( - natPreferencesDataSource.userData.first().accountId, + ) = apiRequest(ioDispatcher) { + api.getShop( + natPreferencesDataSource.currentAccountId(), id, ) - emitApiResponse(response) - }.flowOn(ioDispatcher) + } override fun createShop( shopBody: ShopBody, - ) = flow { - val response = api.createShop( - natPreferencesDataSource.userData.first().accountId, + ) = apiRequest(ioDispatcher) { + api.createShop( + natPreferencesDataSource.currentAccountId(), shopBody, ) - emitApiResponse(response) - }.flowOn(ioDispatcher) + } override fun updateShop( id: String, shopUpdateBody: ShopUpdateBody, - ) = flow { - val response = api.updateShop( - natPreferencesDataSource.userData.first().accountId, + ) = apiRequest(ioDispatcher) { + api.updateShop( + natPreferencesDataSource.currentAccountId(), id, shopUpdateBody, ) - emitApiResponse(response) - }.flowOn(ioDispatcher) + } override fun deleteShop( id: String, - ) = flow { - val response = api.deleteShop(natPreferencesDataSource.userData.first().accountId, id) - emitApiResponse(response) { true } - }.flowOn(ioDispatcher) + ) = apiAction(ioDispatcher) { + api.deleteShop(natPreferencesDataSource.currentAccountId(), id) + } } diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/NativeAppTemplatePreferencesDataSource.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/NativeAppTemplatePreferencesDataSource.kt index c4c6ce2..8dbbd55 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/NativeAppTemplatePreferencesDataSource.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/datastore/NativeAppTemplatePreferencesDataSource.kt @@ -13,6 +13,7 @@ import com.nativeapptemplate.nativeapptemplatefree.model.Permissions import com.nativeapptemplate.nativeapptemplatefree.model.TimeZones import com.nativeapptemplate.nativeapptemplatefree.model.UserData import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import java.io.IOException import javax.inject.Inject @@ -183,6 +184,9 @@ class NativeAppTemplatePreferencesDataSource @Inject constructor( } } + /** The account the signed-in shopkeeper is working in, which most API paths start with. */ + suspend fun currentAccountId(): String = userData.first().accountId + fun isLoggedIn(): Flow = userPreferences.data .map { data -> data.isLoggedIn diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/network/ApiResponseExtensions.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/network/ApiResponseExtensions.kt index dc0d4a5..e3ff6ad 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/network/ApiResponseExtensions.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/network/ApiResponseExtensions.kt @@ -7,7 +7,11 @@ import com.skydoves.sandwich.message import com.skydoves.sandwich.retrofit.statusCode import com.skydoves.sandwich.suspendOnFailure import com.skydoves.sandwich.suspendOnSuccess +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray @@ -49,6 +53,21 @@ suspend inline fun FlowCollector.emitApiResponse( } } +/** + * A cold flow that sends [call] on [dispatcher] each time it is collected and emits the response's + * data, or throws the API error (see [emitApiResponse]). + */ +inline fun apiRequest( + dispatcher: CoroutineDispatcher, + crossinline call: suspend () -> ApiResponse, +): Flow = flow { emitApiResponse(call()) }.flowOn(dispatcher) + +/** [apiRequest] for a call whose response body the app does not use: emits true on success. */ +inline fun apiAction( + dispatcher: CoroutineDispatcher, + crossinline call: suspend () -> ApiResponse, +): Flow = flow { emitApiResponse(call()) { _: T -> true } }.flowOn(dispatcher) + /** * Extracts error details from a failed [ApiResponse] and throws an appropriate exception. * From 3f681bee2a2a354c310381f9fb3f99aff74bb4a7 Mon Sep 17 00:00:00 2001 From: dadachi Date: Tue, 13 Oct 2026 10:45:39 +0900 Subject: [PATCH 5/6] Run the sign-in bootstrap and email-change sign-out in the app scope Sign-in stored the session, synced the locale and fetched the permissions in the sign-in screen's viewModelScope. Storing the session routes to the shop list, which clears that view model and cancels its scope while the permissions request (and the locale request) are still in flight, so a fresh session could run without permissions until the next resume. Likewise the sign-out after an email change ran in Edit Profile's scope and stopped if the screen closed before the session was cleared. SessionLifecycle (@Singleton, @ApplicationScope) now owns these steps: start(), refreshPermissions() and signOut() run in the application scope and the caller awaits them, so the screens behave as before but cancelling them no longer cancels the work. The "fetch permissions, sign out only on 401" logic that SignInEmailAndPasswordViewModel and MainActivityViewModel each carried now lives there once. Ported from NativeAppTemplate-Android #112 (with its runCatchingCancellable helper). New tests (each seen red with the steps run in the caller's scope, i.e. on the code before this commit): - SignInEmailAndPasswordViewModelTest .login_whenTheScreenClosesMidBootstrap_stillStoresThePermissionsAndSyncsTheLocale - ShopkeeperEditViewModelTest .updateShopkeeper_withANewEmail_whenTheScreenClosesDuringTheSignOutRequest_stillSignsOut TestLoginRepository gains an onLogoutRequest hook for the second one. Existing tests only changed how the view models are constructed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH --- AGENTS.md | 1 + .../MainActivityViewModel.kt | 28 ++----- .../coroutines/RunCatchingCancellable.kt | 15 ++++ .../data/login/SessionLifecycle.kt | 75 +++++++++++++++++++ .../SignInEmailAndPasswordViewModel.kt | 53 ++++--------- .../ui/settings/ShopkeeperEditViewModel.kt | 12 +-- .../MainActivityViewModelTest.kt | 2 + .../testing/repository/TestLoginRepository.kt | 7 ++ .../repository/TestSessionLifecycle.kt | 21 ++++++ .../SignInEmailAndPasswordViewModelTest.kt | 44 ++++++++++- .../ui/common/PasswordRequiredMessagesTest.kt | 4 +- .../ui/common/SubmitButtonLabelTest.kt | 3 +- .../ShopkeeperEditViewAppLanguageTest.kt | 3 +- .../settings/ShopkeeperEditViewModelTest.kt | 33 ++++++++ 14 files changed, 228 insertions(+), 73 deletions(-) create mode 100644 app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/common/coroutines/RunCatchingCancellable.kt create mode 100644 app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/SessionLifecycle.kt create mode 100644 app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestSessionLifecycle.kt diff --git a/AGENTS.md b/AGENTS.md index adda6a2..0dd3553 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,7 @@ MVVM layered architecture following [Android Modern App Architecture](https://de - **Dependency injection**: Hilt with `@InstallIn(SingletonComponent::class)`. Repositories are bound via `@Binds` in `DataModule`. - **Navigation routes**: Defined as extension functions on `NavGraphBuilder` (e.g., `shopListView()`, `navigateToShopDetail()`). Routes use type-safe navigation. - **Proto DataStore**: User preferences are persisted via Protocol Buffers (lite). +- **Session steps**: steps that remove the calling screen (sign-in bootstrap, permission refresh, sign-out after an email change) go through `SessionLifecycle` (`data/login/`), which runs them in the `@ApplicationScope` and waits for them, so the screen's `viewModelScope` being cancelled does not cut them short. - **Errors in ViewModels**: a failed load or save shows the error and stops the spinner through `ErrorShowingUiState` (`ui/common/ErrorShowingUiState.kt`): the screen's UI state implements `showingError(message)` once, and flows end with `.catchShowingError(_uiState)` (or `_uiState.showError(exception)` in a `try`/`catch`). Rethrow `CancellationException` before catching `Exception`. ## Error Handling (CodedError System) diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/MainActivityViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/MainActivityViewModel.kt index c45e1f4..95110ce 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/MainActivityViewModel.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/MainActivityViewModel.kt @@ -5,14 +5,14 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nativeapptemplate.nativeapptemplatefree.MainActivityUiState.Loading import com.nativeapptemplate.nativeapptemplatefree.MainActivityUiState.Success -import com.nativeapptemplate.nativeapptemplatefree.common.errors.ApiException +import com.nativeapptemplate.nativeapptemplatefree.common.coroutines.runCatchingCancellable import com.nativeapptemplate.nativeapptemplatefree.data.login.AppLanguageSyncer import com.nativeapptemplate.nativeapptemplatefree.data.login.LoginRepository +import com.nativeapptemplate.nativeapptemplatefree.data.login.SessionLifecycle import com.nativeapptemplate.nativeapptemplatefree.model.UserData import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -23,6 +23,7 @@ import javax.inject.Inject class MainActivityViewModel @Inject constructor( private val loginRepository: LoginRepository, private val appLanguageSyncer: AppLanguageSyncer, + private val sessionLifecycle: SessionLifecycle, ) : ViewModel() { val uiState: StateFlow = loginRepository.userData.map { @@ -48,27 +49,10 @@ class MainActivityViewModel @Inject constructor( fun updatePermissions() { viewModelScope.launch { - val isLoggedIn = loginRepository.isLoggedIn().first() + if (!loginRepository.isLoggedIn().first()) return@launch - if (isLoggedIn) { - loginRepository.getPermissions() - .catch { exception -> - Log.e("MainActivityViewModel", "Failed to update permissions", exception) - - // Only a rejected session means the user is signed out. Being offline or a server error - // must not wipe the session; the cached permissions stay in use until the next resume. - if ((exception as? ApiException)?.isUnauthorized != true) return@catch - - loginRepository.logout() - .catch { logoutException -> - Log.e("MainActivityViewModel", "Logout error", logoutException) - } - .collect { } - } - .collect { permissions -> - loginRepository.setPermissions(permissions) - } - } + runCatchingCancellable { sessionLifecycle.refreshPermissions() } + .onFailure { Log.e("MainActivityViewModel", "Failed to update permissions", it) } } } } diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/common/coroutines/RunCatchingCancellable.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/common/coroutines/RunCatchingCancellable.kt new file mode 100644 index 0000000..e1f4c5d --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/common/coroutines/RunCatchingCancellable.kt @@ -0,0 +1,15 @@ +package com.nativeapptemplate.nativeapptemplatefree.common.coroutines + +import kotlin.coroutines.cancellation.CancellationException + +/** + * Like [runCatching], but rethrows [CancellationException] so a cancelled coroutine stops instead + * of treating its cancellation as an ordinary failure and carrying on. + */ +inline fun runCatchingCancellable(block: () -> T): Result = try { + Result.success(block()) +} catch (cancellation: CancellationException) { + throw cancellation +} catch (throwable: Throwable) { + Result.failure(throwable) +} diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/SessionLifecycle.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/SessionLifecycle.kt new file mode 100644 index 0000000..3bbf079 --- /dev/null +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/data/login/SessionLifecycle.kt @@ -0,0 +1,75 @@ +package com.nativeapptemplate.nativeapptemplatefree.data.login + +import android.util.Log +import com.nativeapptemplate.nativeapptemplatefree.common.errors.ApiException +import com.nativeapptemplate.nativeapptemplatefree.di.modules.ApplicationScope +import com.nativeapptemplate.nativeapptemplatefree.model.LoggedInShopkeeper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +private const val TAG = "SessionLifecycle" + +/** + * Starts, refreshes and ends the signed-in session. + * + * Each step runs in the application scope and the caller waits for it: the screens that call these + * are removed by the very change they make (storing a session leaves the sign-in screen, clearing + * it leaves Edit Profile), which cancels their viewModelScope while the step still waits on the + * server. Cancelling the caller only stops the wait, not the step. + */ +@Singleton +class SessionLifecycle @Inject constructor( + private val loginRepository: LoginRepository, + private val appLanguageSyncer: AppLanguageSyncer, + @param:ApplicationScope private val scope: CoroutineScope, +) { + /** + * Replaces any previous session with [loggedInShopkeeper], then brings it up to date: the + * shopkeeper's locale in the background, and the permissions, which this waits for. + * + * Throws what [refreshPermissions] throws. + */ + suspend fun start(loggedInShopkeeper: LoggedInShopkeeper) = inApplicationScope { + loginRepository.clearSession() + loginRepository.setShopkeeper(loggedInShopkeeper) + // The account may have been created, or last used, in another language. + scope.launch { appLanguageSyncer.sync() } + fetchPermissions() + } + + /** + * Fetches and stores the permissions. On failure the cached permissions stay in use and the + * failure is thrown; only a rejected session (401) signs the shopkeeper out first, as being + * offline or a server error must not wipe the session. + */ + suspend fun refreshPermissions() = inApplicationScope { fetchPermissions() } + + /** + * Signs out on the server, then locally with [notice] (see [LoginRepository.logout]). The session + * is cleared even when the request fails; that failure is then thrown. + */ + suspend fun signOut(notice: SignOutNotice? = null) = inApplicationScope { + loginRepository.logout(notice).collect { } + } + + private suspend fun fetchPermissions() { + val permissions = loginRepository.getPermissions() + .catch { exception -> + if ((exception as? ApiException)?.isUnauthorized == true) { + loginRepository.logout() + .catch { logoutException -> Log.e(TAG, "Logout error", logoutException) } + .collect { } + } + throw exception + } + .first() + loginRepository.setPermissions(permissions) + } + + private suspend fun inApplicationScope(block: suspend CoroutineScope.() -> T): T = scope.async(block = block).await() +} diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModel.kt index 1cdd85c..ccb284e 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModel.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModel.kt @@ -4,22 +4,20 @@ import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nativeapptemplate.nativeapptemplatefree.NativeAppTemplateConstants -import com.nativeapptemplate.nativeapptemplatefree.common.errors.ApiException -import com.nativeapptemplate.nativeapptemplatefree.common.errors.codedDescription -import com.nativeapptemplate.nativeapptemplatefree.data.login.AppLanguageSyncer import com.nativeapptemplate.nativeapptemplatefree.data.login.LoginRepository +import com.nativeapptemplate.nativeapptemplatefree.data.login.SessionLifecycle import com.nativeapptemplate.nativeapptemplatefree.model.LoggedInShopkeeper import com.nativeapptemplate.nativeapptemplatefree.model.Login -import com.nativeapptemplate.nativeapptemplatefree.model.Permissions import com.nativeapptemplate.nativeapptemplatefree.ui.common.ErrorShowingUiState import com.nativeapptemplate.nativeapptemplatefree.ui.common.catchShowingError +import com.nativeapptemplate.nativeapptemplatefree.ui.common.showError import com.nativeapptemplate.nativeapptemplatefree.utils.Utility.isValidEmail import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -39,7 +37,7 @@ data class SignInEmailAndPasswordUiState( @HiltViewModel class SignInEmailAndPasswordViewModel @Inject constructor( private val loginRepository: LoginRepository, - private val appLanguageSyncer: AppLanguageSyncer, + private val sessionLifecycle: SessionLifecycle, ) : ViewModel() { private val _uiState = MutableStateFlow(SignInEmailAndPasswordUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -59,39 +57,16 @@ class SignInEmailAndPasswordViewModel @Inject constructor( .catchShowingError(_uiState) .collect { loggedInShopkeeper -> _uiState.update { it.copy(loggedInShopkeeper = loggedInShopkeeper) } - loginRepository.clearSession() - loginRepository.setShopkeeper(loggedInShopkeeper) - // The account may have been created, or last used, in another language. - viewModelScope.launch { appLanguageSyncer.sync() } - - val permissionsFlow: Flow = loginRepository.getPermissions() - - permissionsFlow - .catch { exception -> - Log.e("SignInEmailAndPasswordViewModel", "Failed to update permissions", exception) - _uiState.update { - it.copy( - message = exception.codedDescription, - isLoading = false, - ) - } - - // Only a rejected session signs the user out; otherwise MainActivityViewModel - // fetches the permissions again on the next resume. - if ((exception as? ApiException)?.isUnauthorized != true) return@catch - - val booleanFlow = loginRepository.logout() - booleanFlow - .catch { logoutException -> - Log.e("SignInEmailAndPasswordViewModel", "Logout error", logoutException) - } - .collect { - } - } - .collect { permissions -> - loginRepository.setPermissions(permissions) - _uiState.update { it.copy(isLoading = false) } - } + try { + sessionLifecycle.start(loggedInShopkeeper) + _uiState.update { it.copy(isLoading = false) } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (exception: Exception) { + // MainActivityViewModel fetches the permissions again on the next resume. + Log.e("SignInEmailAndPasswordViewModel", "Failed to update permissions", exception) + _uiState.showError(exception) + } } } } diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModel.kt index 5e342f2..f58a35f 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModel.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModel.kt @@ -3,9 +3,11 @@ package com.nativeapptemplate.nativeapptemplatefree.ui.settings import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.nativeapptemplate.nativeapptemplatefree.common.coroutines.runCatchingCancellable import com.nativeapptemplate.nativeapptemplatefree.common.errors.codedDescription import com.nativeapptemplate.nativeapptemplatefree.data.login.AppLanguageProvider import com.nativeapptemplate.nativeapptemplatefree.data.login.LoginRepository +import com.nativeapptemplate.nativeapptemplatefree.data.login.SessionLifecycle import com.nativeapptemplate.nativeapptemplatefree.data.login.SignOutNotice import com.nativeapptemplate.nativeapptemplatefree.data.login.SignUpRepository import com.nativeapptemplate.nativeapptemplatefree.model.SignUpForUpdate @@ -48,6 +50,7 @@ class ShopkeeperEditViewModel @Inject constructor( private val loginRepository: LoginRepository, private val signUpRepository: SignUpRepository, private val appLanguageProvider: AppLanguageProvider, + private val sessionLifecycle: SessionLifecycle, ) : ViewModel() { private val _uiState = MutableStateFlow(ShopkeeperEditUiState()) @@ -131,12 +134,9 @@ class ShopkeeperEditViewModel @Inject constructor( ) } - // logout clears the session (storing the notice) even when the request fails. - loginRepository.logout(SignOutNotice.EMAIL_UPDATED) - .catch { exception -> - Log.e("ShopkeeperEditViewModel", "Logout error", exception) - } - .collect { } + // signOut clears the session (storing the notice) even when the request fails. + runCatchingCancellable { sessionLifecycle.signOut(SignOutNotice.EMAIL_UPDATED) } + .onFailure { Log.e("ShopkeeperEditViewModel", "Logout error", it) } } else { _uiState.update { it.copy( diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/MainActivityViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/MainActivityViewModelTest.kt index 6631b01..d597ad6 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/MainActivityViewModelTest.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/MainActivityViewModelTest.kt @@ -12,6 +12,7 @@ import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestAppLan import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestLoginRepository import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestSignUpRepository import com.nativeapptemplate.nativeapptemplatefree.testing.repository.emptyUserData +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.testSessionLifecycle import com.nativeapptemplate.nativeapptemplatefree.testing.util.MainDispatcherRule import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.first @@ -44,6 +45,7 @@ class MainActivityViewModelTest { viewModel = MainActivityViewModel( loginRepository = loginRepository, appLanguageSyncer = AppLanguageSyncer(loginRepository, signUpRepository, appLanguageProvider), + sessionLifecycle = testSessionLifecycle(loginRepository, signUpRepository, appLanguageProvider), ) } diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestLoginRepository.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestLoginRepository.kt index 724c37b..17617a1 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestLoginRepository.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestLoginRepository.kt @@ -42,6 +42,7 @@ class TestLoginRepository : LoginRepository { override fun logout(notice: SignOutNotice?): Flow = flow { logoutCallCount++ + onLogoutRequest() // LoginRepositoryImpl.logout clears the session whether or not the request succeeds. storeSignOutNotice(notice) onSessionCleared() @@ -55,6 +56,12 @@ class TestLoginRepository : LoginRepository { */ var onSessionCleared: suspend () -> Unit = {} + /** + * Runs while [logout] waits on the server, before the session is cleared. A test can close the + * calling screen here, as the user can while the request is in flight. + */ + var onLogoutRequest: suspend () -> Unit = {} + private var permissionsError: Throwable? = null override fun getPermissions(): Flow = permissionsError?.let { error -> flow { throw error } } ?: permissionsFlow diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestSessionLifecycle.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestSessionLifecycle.kt new file mode 100644 index 0000000..11d05e1 --- /dev/null +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/testing/repository/TestSessionLifecycle.kt @@ -0,0 +1,21 @@ +package com.nativeapptemplate.nativeapptemplatefree.testing.repository + +import com.nativeapptemplate.nativeapptemplatefree.data.login.AppLanguageSyncer +import com.nativeapptemplate.nativeapptemplatefree.data.login.SessionLifecycle +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + +/** + * A [SessionLifecycle] over the test doubles. Its application scope runs on the main dispatcher, + * which `MainDispatcherRule` replaces with the test's, and outlives any view model's scope. + */ +fun testSessionLifecycle( + loginRepository: TestLoginRepository, + signUpRepository: TestSignUpRepository = TestSignUpRepository(), + appLanguageProvider: TestAppLanguageProvider = TestAppLanguageProvider("en"), +) = SessionLifecycle( + loginRepository = loginRepository, + appLanguageSyncer = AppLanguageSyncer(loginRepository, signUpRepository, appLanguageProvider), + scope = CoroutineScope(SupervisorJob() + Dispatchers.Main), +) diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModelTest.kt index 01f66fb..9525104 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModelTest.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/app_root/SignInEmailAndPasswordViewModelTest.kt @@ -1,7 +1,7 @@ package com.nativeapptemplate.nativeapptemplatefree.ui.app_root +import androidx.lifecycle.viewModelScope import com.nativeapptemplate.nativeapptemplatefree.common.errors.ApiException -import com.nativeapptemplate.nativeapptemplatefree.data.login.AppLanguageSyncer import com.nativeapptemplate.nativeapptemplatefree.model.Attributes import com.nativeapptemplate.nativeapptemplatefree.model.Data import com.nativeapptemplate.nativeapptemplatefree.model.LoggedInShopkeeper @@ -10,7 +10,9 @@ import com.nativeapptemplate.nativeapptemplatefree.model.Permissions import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestAppLanguageProvider import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestLoginRepository import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestSignUpRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.testSessionLifecycle import com.nativeapptemplate.nativeapptemplatefree.testing.util.MainDispatcherRule +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -42,7 +44,7 @@ class SignInEmailAndPasswordViewModelTest { fun setUp() { viewModel = SignInEmailAndPasswordViewModel( loginRepository = loginRepository, - appLanguageSyncer = AppLanguageSyncer(loginRepository, signUpRepository, appLanguageProvider), + sessionLifecycle = testSessionLifecycle(loginRepository, signUpRepository, appLanguageProvider), ) } @@ -198,6 +200,44 @@ class SignInEmailAndPasswordViewModelTest { assertFalse(uiStateValue.isLoading) } + // Storing the session routes away from the sign-in screen, which clears this view model and + // cancels its scope while the permissions request is still in flight. The permissions and the + // locale sync must still land, or the new session runs without permissions until the next resume. + @Test + fun login_whenTheScreenClosesMidBootstrap_stillStoresThePermissionsAndSyncsTheLocale() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + viewModel.updateEmail(testInputLoggedInShopkeeper.getEmail()!!) + viewModel.updatePassword(testInputPassword) + loginRepository.sendLoggedInShopkeeper( + testInputLoggedInShopkeeper.copy( + datum = testInputLoggedInShopkeeper.datum!!.copy( + attributes = testInputLoggedInShopkeeper.datum!!.attributes!!.copy(locale = "en"), + ), + ), + ) + appLanguageProvider.code = "ja" + // Neither the permissions nor the locale request has a response yet when the screen closes. + + viewModel.login() + assertTrue(loginRepository.userData.first().isLoggedIn) + // The route to the shop list clears this view model. + viewModel.viewModelScope.cancel() + + loginRepository.sendPermissions(testInputPermissions) + signUpRepository.sendLoggedInShopkeeper( + testInputLoggedInShopkeeper.copy( + datum = testInputLoggedInShopkeeper.datum!!.copy( + attributes = testInputLoggedInShopkeeper.datum!!.attributes!!.copy(locale = "ja"), + ), + ), + ) + + val userData = loginRepository.userData.first() + // testInputPermissions' meta.shop_limit_count. + assertEquals(PERMISSION_SHOP_LIMIT_COUNT, userData.shopLimitCount) + assertEquals("ja", userData.locale) + } + @Test fun blankEmail_isInvalid() = runTest { backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/PasswordRequiredMessagesTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/PasswordRequiredMessagesTest.kt index d5ab1ba..857e464 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/PasswordRequiredMessagesTest.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/PasswordRequiredMessagesTest.kt @@ -2,11 +2,11 @@ package com.nativeapptemplate.nativeapptemplatefree.ui.common import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText -import com.nativeapptemplate.nativeapptemplatefree.data.login.AppLanguageSyncer import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestAccountPasswordRepository import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestAppLanguageProvider import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestLoginRepository import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestSignUpRepository +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.testSessionLifecycle import com.nativeapptemplate.nativeapptemplatefree.ui.app_root.SignInEmailAndPasswordView import com.nativeapptemplate.nativeapptemplatefree.ui.app_root.SignInEmailAndPasswordViewModel import com.nativeapptemplate.nativeapptemplatefree.ui.app_root.SignUpView @@ -36,7 +36,7 @@ class PasswordRequiredMessagesTest { fun signIn_withNoPassword_asksForThePassword() { val viewModel = SignInEmailAndPasswordViewModel( loginRepository = loginRepository, - appLanguageSyncer = AppLanguageSyncer(loginRepository, signUpRepository, appLanguageProvider), + sessionLifecycle = testSessionLifecycle(loginRepository, signUpRepository, appLanguageProvider), ) composeTestRule.setContent { SignInEmailAndPasswordView( diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/SubmitButtonLabelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/SubmitButtonLabelTest.kt index 447fa6b..ec02089 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/SubmitButtonLabelTest.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/common/SubmitButtonLabelTest.kt @@ -20,6 +20,7 @@ import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestLoginR import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestShopRepository import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestSignUpRepository import com.nativeapptemplate.nativeapptemplatefree.testing.repository.emptyUserData +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.testSessionLifecycle import com.nativeapptemplate.nativeapptemplatefree.ui.app_root.ForgotPasswordView import com.nativeapptemplate.nativeapptemplatefree.ui.app_root.ForgotPasswordViewModel import com.nativeapptemplate.nativeapptemplatefree.ui.app_root.ResendConfirmationInstructionsView @@ -84,7 +85,7 @@ class SubmitButtonLabelTest { @Test fun shopkeeperEdit() { val loginRepository = TestLoginRepository().apply { sendUserData(emptyUserData.copy(name = "John", email = "john@example.com")) } - val viewModel = ShopkeeperEditViewModel(loginRepository, TestSignUpRepository(), TestAppLanguageProvider()) + val viewModel = ShopkeeperEditViewModel(loginRepository, TestSignUpRepository(), TestAppLanguageProvider(), testSessionLifecycle(loginRepository)) assertSubmitButtonLabeled(R.string.button_label_update) { ShopkeeperEditView(viewModel = viewModel, onShowSnackbar = onShowSnackbar, onBackClick = {}) } diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewAppLanguageTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewAppLanguageTest.kt index f9f54a0..b7f37cf 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewAppLanguageTest.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewAppLanguageTest.kt @@ -12,6 +12,7 @@ import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestAppLan import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestLoginRepository import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestSignUpRepository import com.nativeapptemplate.nativeapptemplatefree.testing.repository.emptyUserData +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.testSessionLifecycle import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Rule @@ -34,7 +35,7 @@ class ShopkeeperEditViewAppLanguageTest { val loginRepository = TestLoginRepository().apply { sendUserData(emptyUserData.copy(isLoggedIn = true, name = "John", email = "john@example.com", locale = "en")) } - val viewModel = ShopkeeperEditViewModel(loginRepository, TestSignUpRepository(), TestAppLanguageProvider("ja")) + val viewModel = ShopkeeperEditViewModel(loginRepository, TestSignUpRepository(), TestAppLanguageProvider("ja"), testSessionLifecycle(loginRepository)) composeTestRule.setContent { ShopkeeperEditView(viewModel = viewModel, onShowSnackbar = { _, _, _ -> true }, onBackClick = {}) } diff --git a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModelTest.kt b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModelTest.kt index 372ca1c..eedef4a 100644 --- a/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModelTest.kt +++ b/app/src/test/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModelTest.kt @@ -9,6 +9,7 @@ import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestAppLan import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestLoginRepository import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestSignUpRepository import com.nativeapptemplate.nativeapptemplatefree.testing.repository.emptyUserData +import com.nativeapptemplate.nativeapptemplatefree.testing.repository.testSessionLifecycle import com.nativeapptemplate.nativeapptemplatefree.testing.util.MainDispatcherRule import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.collect @@ -45,6 +46,7 @@ class ShopkeeperEditViewModelTest { loginRepository = loginRepository, signUpRepository = signUpRepository, appLanguageProvider = appLanguageProvider, + sessionLifecycle = testSessionLifecycle(loginRepository, signUpRepository, appLanguageProvider), ) } @@ -191,6 +193,37 @@ class ShopkeeperEditViewModelTest { assertTrue(loginRepository.userData.first().isEmailUpdated) } + // The screen can also close before the session is cleared, e.g. the user leaves Edit Profile + // while the sign-out request is in flight. The email did change, so the sign-out must still finish. + @Test + fun updateShopkeeper_withANewEmail_whenTheScreenClosesDuringTheSignOutRequest_stillSignsOut() = runTest { + backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } + loginRepository.sendUserData( + emptyUserData.copy( + name = testInputLoggedInShopkeeper.getName()!!, + email = testInputLoggedInShopkeeper.getEmail()!!, + timeZone = testInputLoggedInShopkeeper.getTimeZone()!!, + ), + ) + signUpRepository.sendLoggedInShopkeeper( + LoggedInShopkeeper( + datum = Data( + id = LOGGED_IN_SHOPKEEPER_ID, + type = LOGGED_IN_SHOPKEEPER_TYPE, + attributes = testInputLoggedInShopkeeper.datum!!.attributes!!.copy(email = testInputNewEmail), + ), + ), + ) + viewModel.reload() + viewModel.updateEmail(testInputNewEmail) + loginRepository.onLogoutRequest = { leaveTheScreen() } + + viewModel.updateShopkeeper() + + // TestLoginRepository.logout stores the notice in the same step that clears the session. + assertTrue(loginRepository.userData.first().isEmailUpdated) + } + // As with the email change: the "account deleted" message must survive the screen closing. @Test fun deleteShopkeeper_whenTheScreenClosesOnSignOut_stillReportsTheDeletion() = runTest { From cb0e30c7fc2380372a856714da2c5d0d1afeb761 Mon Sep 17 00:00:00 2001 From: dadachi Date: Tue, 13 Oct 2026 10:46:13 +0900 Subject: [PATCH 6/6] Share the item tag toggle and the account deletion error path - ItemTagDetailViewModel.completeItemTag and idleItemTag were the same function apart from the repository call; both now go through toggle(). - ShopkeeperEditViewModel.deleteShopkeeper wrote out by hand what catchShowingError does (message, loading stopped); use it. No test changed (ItemTagDetailViewModelTest and ShopkeeperEditViewModelTest cover both paths). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH --- .../ui/settings/ShopkeeperEditViewModel.kt | 14 ++------- .../item_tag_detail/ItemTagDetailViewModel.kt | 31 +++---------------- 2 files changed, 6 insertions(+), 39 deletions(-) diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModel.kt index f58a35f..79111ce 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModel.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModel.kt @@ -4,7 +4,6 @@ import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nativeapptemplate.nativeapptemplatefree.common.coroutines.runCatchingCancellable -import com.nativeapptemplate.nativeapptemplatefree.common.errors.codedDescription import com.nativeapptemplate.nativeapptemplatefree.data.login.AppLanguageProvider import com.nativeapptemplate.nativeapptemplatefree.data.login.LoginRepository import com.nativeapptemplate.nativeapptemplatefree.data.login.SessionLifecycle @@ -22,7 +21,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -161,16 +159,8 @@ class ShopkeeperEditViewModel @Inject constructor( val booleanFlow: Flow = signUpRepository.deleteAccount() booleanFlow - .catch { exception -> - // The account still exists on the server, so keep the session and let the user retry. - val message = exception.codedDescription - _uiState.update { - it.copy( - message = message, - isLoading = false, - ) - } - } + // The account still exists on the server, so keep the session and let the user retry. + .catchShowingError(_uiState) .collect { _uiState.update { it.copy( diff --git a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagDetailViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagDetailViewModel.kt index c365e34..c25457d 100644 --- a/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagDetailViewModel.kt +++ b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_settings/item_tag_detail/ItemTagDetailViewModel.kt @@ -101,38 +101,15 @@ class ItemTagDetailViewModel @Inject constructor( } } - fun completeItemTag() { - _uiState.update { it.copy(isToggling = true) } + fun completeItemTag() = toggle(itemTagRepository.completeItemTag(itemTagId)) - viewModelScope.launch { - val itemTagFlow: Flow = itemTagRepository.completeItemTag(itemTagId) + fun idleItemTag() = toggle(itemTagRepository.idleItemTag(itemTagId)) - itemTagFlow - .catch { exception -> - _uiState.update { - it.copy( - message = exception.codedDescription, - isToggling = false, - ) - } - } - .collect { itemTag -> - _uiState.update { - it.copy( - itemTag = itemTag, - isToggling = false, - ) - } - } - } - } - - fun idleItemTag() { + /** Shows the item tag [itemTagFlow] returns after completing or idling it. */ + private fun toggle(itemTagFlow: Flow) { _uiState.update { it.copy(isToggling = true) } viewModelScope.launch { - val itemTagFlow: Flow = itemTagRepository.idleItemTag(itemTagId) - itemTagFlow .catch { exception -> _uiState.update {