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/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/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/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. * 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 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/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/settings/ShopkeeperEditViewModel.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/settings/ShopkeeperEditViewModel.kt index 5e342f2..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 @@ -3,9 +3,10 @@ package com.nativeapptemplate.nativeapptemplatefree.ui.settings import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.nativeapptemplate.nativeapptemplatefree.common.errors.codedDescription +import com.nativeapptemplate.nativeapptemplatefree.common.coroutines.runCatchingCancellable 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 @@ -20,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 @@ -48,6 +48,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 +132,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( @@ -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_detail/ShopDetailView.kt b/app/src/main/kotlin/com/nativeapptemplate/nativeapptemplatefree/ui/shop_detail/ShopDetailView.kt index e78059d..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.getDatumWithRelationships().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_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 { 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/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/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/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/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 { 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..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 ?: "" @@ -22,16 +21,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