Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Fixed
- Error messages are in the app language and say what the user can do, with the same wording as NativeAppTemplate-Free-iOS; the error codes are unchanged. A request that got no answer (no network, a timeout) or an error status without a message (e.g. a 502 page) shows `[NATIVEAPPTEMPLATE-2002]` "Something went wrong while communicating with the server. Please try again later." (サーバーとの通信中に問題が発生しました。しばらくしてからもう一度お試しください。) instead of the English system text (e.g. "Processing error: Unable to resolve host ..." or "Processing error: HTTP 502"). A response the app could not process (`2002`) and `1001` show "An unexpected error occurred." (予期しないエラーが発生しました。). The server's own messages are shown as is, without the developer suffix " [Status: 422]" (`2001`) or the "Processing error: " prefix (sign-in's "Invalid login credentials"). The details are logged with the code instead (`adb logcat -s ApiError`)
- An error snackbar no longer stays after the screen recovers (e.g. the offline `[NATIVEAPPTEMPLATE-2002]` error still showing after the network is back and Reload succeeded). Every screen's load (Reload, pull to refresh, resume) and every sign-in attempt now clears the previous attempt's error when it starts, which closes its snackbar; an error of the new attempt still shows. Ported from MyTurnTagCreator-Android #139

## [3.2.6] - 2026-05-24

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ class SignInEmailAndPasswordViewModel @Inject constructor(
_uiState.update {
it.copy(
isLoading = true,
// A new attempt: the last one's error is stale (its snackbar closes).
message = "",
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import kotlinx.coroutines.flow.update
* A screen's UI state that shows a failed load or save: the error in the snackbar, and the
* spinner stopped. Implementing it once per state keeps every failure path of the screen the same,
* so none of them can forget to stop the spinner.
*
* A new attempt (a load, a reload from the error view, pull to refresh or resume, a sign-in) sets
* the message back to "" when it starts: the last attempt's error is stale, and clearing it closes
* its snackbar (`SnackbarMessageEffect` cancels the shown one). The new attempt's own error still
* shows.
*/
interface ErrorShowingUiState<S : ErrorShowingUiState<S>> {
/** This state showing [message], with loading stopped. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class SettingsViewModel @Inject constructor(
_uiState.update {
it.copy(
isLoading = true,
message = "",
success = false,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ class ShopkeeperEditViewModel @Inject constructor(
_uiState.update {
it.copy(
isLoading = true,
message = "",
success = false,
isUpdated = false,
isEmailUpdated = false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ class ShopDetailViewModel @Inject constructor(
_uiState.update {
it.copy(
isLoading = true,
message = "",
success = false,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ class ShopBasicSettingsViewModel @Inject constructor(
_uiState.update {
it.copy(
isLoading = true,
message = "",
success = false,
isUpdated = false,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ class ShopSettingsViewModel @Inject constructor(
_uiState.update {
it.copy(
isLoading = true,
message = "",
success = false,
isShopDeleted = false,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class ItemTagDetailViewModel @Inject constructor(
_uiState.update {
it.copy(
isLoading = true,
message = "",
success = false,
isDeleted = false,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ class ItemTagEditViewModel @Inject constructor(
_uiState.update {
it.copy(
isLoading = true,
message = "",
success = false,
isUpdated = false,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ class ItemTagListViewModel @Inject constructor(
_uiState.update {
it.copy(
isLoading = true,
message = "",
success = false,
loadMoreFailed = false,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ class ShopListViewModel @Inject constructor(
_uiState.update {
it.copy(
isLoading = true,
message = "",
success = false,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ class CallFailures {
errors[call.name] = error
}

/** Makes later [call]s succeed again, e.g. once the network is back. */
fun succeed(call: KFunction<*>) {
errors.remove(call.name)
}

/** [success], or a flow that throws the error set for [call] by [fail]. */
fun <T> orFail(call: KFunction<*>, success: Flow<T>): Flow<T> = errors[call.name]?.let { error -> flow { throw error } } ?: success
}
Expand All @@ -35,4 +40,7 @@ interface FailableRepository {

/** Makes every later [call] fail with [error]. */
fun fail(call: KFunction<*>, error: Throwable) = failures.fail(call, error)

/** Makes later [call]s succeed again. */
fun succeed(call: KFunction<*>) = failures.succeed(call)
}
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,8 @@ class TestLoginRepository : LoginRepository {
permissionsFlow.tryEmit(permissions)
}

/** Makes every later [login] call fail with [error]. */
fun failLogin(error: Throwable) {
/** Makes every later [login] call fail with [error], or succeed again when null. */
fun failLogin(error: Throwable?) {
loginError = error
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestLoginR
import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestSignUpRepository
import com.nativeapptemplate.nativeapptemplatefree.testing.repository.testSessionLifecycle
import com.nativeapptemplate.nativeapptemplatefree.testing.util.MainDispatcherRule
import com.nativeapptemplate.nativeapptemplatefree.testing.util.TEST_API_ERROR_MESSAGE
import com.nativeapptemplate.nativeapptemplatefree.testing.util.testApiError
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
Expand Down Expand Up @@ -127,6 +129,40 @@ class SignInEmailAndPasswordViewModelTest {
assertEquals(0, loginRepository.logoutCallCount)
}

// Bug: a new sign-in attempt kept the previous attempt's error, so its snackbar stayed on
// screen after the next sign-in succeeded (as in MyTurnTagCreator-Android #139).
@Test
fun login_afterAFailedAttempt_clearsTheStaleError() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
viewModel.updateEmail(testInputLoggedInShopkeeper.getEmail()!!)
viewModel.updatePassword(testInputPassword)
loginRepository.failLogin(testApiError)
viewModel.login()
assertEquals(TEST_API_ERROR_MESSAGE, viewModel.uiState.value.message)

loginRepository.failLogin(null)
loginRepository.sendLoggedInShopkeeper(testInputLoggedInShopkeeper)
loginRepository.sendPermissions(testInputPermissions)
viewModel.login()

// The successful attempt shows no error, so the old one must be gone.
assertEquals("", viewModel.uiState.value.message)
}

@Test
fun login_whenAnotherAttemptFails_showsItsError() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
viewModel.updateEmail(testInputLoggedInShopkeeper.getEmail()!!)
viewModel.updatePassword(testInputPassword)
loginRepository.failLogin(testApiError)
viewModel.login()

viewModel.login()

// Cleared when the attempt starts, then set again by its own failure.
assertEquals(TEST_API_ERROR_MESSAGE, viewModel.uiState.value.message)
}

@Test
fun login_whenTheCredentialsAreRejected_showsTheServerMessageAndSavesNothing() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.nativeapptemplate.nativeapptemplatefree.ui.common

import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.junit4.v2.createComposeRule
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
class SnackbarMessageEffectTest {
@get:Rule
val composeTestRule = createComposeRule()

// A view model clears its message when a new attempt starts (ErrorShowingUiState). That must
// close the last attempt's snackbar, which stays until dismissed (Indefinite), and a failure of
// the new attempt must show again, even with the same text.
@Test
fun clearingTheMessage_closesItsSnackbar_andTheNextErrorShowsAgain() {
val host = SnackbarHostState()
var message by mutableStateOf("")
composeTestRule.setContent {
SnackbarMessageEffect(
message = message,
// As the app root passes it to the screens.
onShowSnackbar = { text, action, duration ->
host.showSnackbar(text, action, duration = duration) == SnackbarResult.ActionPerformed
},
onMessageShown = { message = "" },
)
}

composeTestRule.runOnIdle { message = OFFLINE_ERROR }
composeTestRule.waitForIdle()
assertEquals(OFFLINE_ERROR, host.currentSnackbarData?.visuals?.message)

// Reload starts.
composeTestRule.runOnIdle { message = "" }
composeTestRule.waitForIdle()
assertNull(host.currentSnackbarData)

// Reload fails again.
composeTestRule.runOnIdle { message = OFFLINE_ERROR }
composeTestRule.waitForIdle()
assertEquals(OFFLINE_ERROR, host.currentSnackbarData?.visuals?.message)
}
}

private const val OFFLINE_ERROR = "[NATIVEAPPTEMPLATE-2002] Something went wrong while communicating with the server. Please try again later."
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ package com.nativeapptemplate.nativeapptemplatefree.ui.shops

import androidx.compose.runtime.getValue
import androidx.compose.ui.test.junit4.v2.createComposeRule
import com.nativeapptemplate.nativeapptemplatefree.data.shop.ShopRepository
import com.nativeapptemplate.nativeapptemplatefree.model.Attributes
import com.nativeapptemplate.nativeapptemplatefree.model.Data
import com.nativeapptemplate.nativeapptemplatefree.model.Shops
import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestLoginRepository
import com.nativeapptemplate.nativeapptemplatefree.testing.repository.TestShopRepository
import com.nativeapptemplate.nativeapptemplatefree.testing.util.MainDispatcherRule
import com.nativeapptemplate.nativeapptemplatefree.testing.util.TEST_API_ERROR_MESSAGE
import com.nativeapptemplate.nativeapptemplatefree.testing.util.testApiError
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
Expand Down Expand Up @@ -63,6 +66,34 @@ class ShopListViewModelTest {
assertEquals(shopsFromRepository, uiStateValue.shops)
}

// Bug: offline, the load failed; back online, Reload succeeded but the error snackbar stayed
// until dismissed. A new attempt clears the stale error (as in MyTurnTagCreator-Android #139).
@Test
fun reload_afterAFailedLoad_clearsTheStaleError() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
shopRepository.fail(ShopRepository::getShops, testApiError)
viewModel.reload()
assertEquals(TEST_API_ERROR_MESSAGE, viewModel.uiState.value.message)

shopRepository.succeed(ShopRepository::getShops)
shopRepository.sendShops(testInputShops)
viewModel.reload()

assertTrue(viewModel.uiState.value.success)
assertEquals("", viewModel.uiState.value.message)
}

@Test
fun reload_whenItFailsAgain_showsTheNewError() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
shopRepository.fail(ShopRepository::getShops, testApiError)
viewModel.reload()

viewModel.reload()

assertEquals(TEST_API_ERROR_MESSAGE, viewModel.uiState.value.message)
}

@Test
fun reload_replacesThePreviousCollectionInsteadOfAddingOne() = runTest {
viewModel.reload()
Expand Down
Loading