diff --git a/app/src/main/java/com/github/se/pooltrack/AppGate.kt b/app/src/main/java/com/github/se/pooltrack/AppGate.kt new file mode 100644 index 0000000..4180ddb --- /dev/null +++ b/app/src/main/java/com/github/se/pooltrack/AppGate.kt @@ -0,0 +1,47 @@ +package com.github.se.pooltrack + +import com.github.se.pooltrack.ui.poolstay.PoolStayState + +/** The screen that takes over the app, or [App] when none does. */ +enum class AppGate { + /** Waiting for the stored entries, so nothing is shown yet (a blank frame, not a flash). */ + Loading, + + /** The user has to log how far they swam, see `PoolStayScreen`. */ + PoolStay, + + /** A newer release has to be installed, see `UpdateRequiredScreen`. */ + Update, + + /** Nobody is signed in with a real account. */ + SignIn, + + /** The normal app. */ + App, +} + +/** + * Which screen takes over the app, in order of priority. + * + * A swim waiting for its distance comes before the mandatory update: the update replaces the whole + * app and can take a while on the weak signal of a pool, and the user must not be kept from logging + * a swim they just finished. The distance is stored with the entry, so the update is simply offered + * right after it is logged. + * + * @param updateAvailable Whether a newer release has to be installed. + * @param signedIn Whether a real (non-anonymous) account is signed in; entries, and so swims, only + * exist for it. + * @param poolStay Where the user's current stay in the pool stands. + */ +fun appGate(updateAvailable: Boolean, signedIn: Boolean, poolStay: PoolStayState): AppGate = + when { + signedIn && poolStay.needsAttention() -> AppGate.PoolStay + // Not known yet whether a swim is waiting: show nothing rather than the update for a moment. + signedIn && poolStay == PoolStayState.Loading -> AppGate.Loading + updateAvailable -> AppGate.Update + !signedIn -> AppGate.SignIn + else -> AppGate.App + } + +private fun PoolStayState.needsAttention() = + this is PoolStayState.CancelWindow || this is PoolStayState.LogRequired diff --git a/app/src/main/java/com/github/se/pooltrack/MainActivity.kt b/app/src/main/java/com/github/se/pooltrack/MainActivity.kt index 80d55d8..f7f43eb 100644 --- a/app/src/main/java/com/github/se/pooltrack/MainActivity.kt +++ b/app/src/main/java/com/github/se/pooltrack/MainActivity.kt @@ -42,7 +42,6 @@ import com.github.se.pooltrack.ui.navigation.ENTRY_TIMESTAMP_ARG import com.github.se.pooltrack.ui.navigation.NavigationActions import com.github.se.pooltrack.ui.navigation.Screen import com.github.se.pooltrack.ui.poolstay.PoolStayScreen -import com.github.se.pooltrack.ui.poolstay.PoolStayState import com.github.se.pooltrack.ui.poolstay.PoolStayViewModel import com.github.se.pooltrack.ui.subscription.SubscriptionQuickViewScreen import com.github.se.pooltrack.ui.subscription.SubscriptionScreen @@ -108,10 +107,11 @@ class MainActivity : ComponentActivity() { * `PoolTrackApp` is the main composable function that sets up the whole app UI. Gated behind Google * sign-in - [SignInScreen] is shown instead until a real (non-anonymous) account is signed in - * since subscriptions and entries are only meaningful once backed up to one. A newer release takes - * precedence over both: [UpdateRequiredScreen] replaces the whole app until it is installed. If the + * precedence over that: [UpdateRequiredScreen] replaces the whole app until it is installed. If the * check can't reach GitHub (e.g. offline at the pool), the app stays usable so the pass can still * be shown. Once signed in, a stay in the pool replaces the app too ([PoolStayScreen]) until the - * user logged how far they swam. + * user logged how far they swam, and that comes even before the update, so a swim that just ended + * can be logged first. See [appGate] for the exact order. */ @Composable fun PoolTrackApp( @@ -120,31 +120,34 @@ fun PoolTrackApp( ) { LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { updateViewModel.onResume() } val updateState by updateViewModel.state.collectAsState() - if (updateState !is UpdateUiState.None) { - UpdateRequiredScreen(updateViewModel) - return - } - val currentUser by accountViewModel.currentUser.collectAsState() val user = currentUser - - if (user == null || user.isAnonymous) { - SignInScreen(viewModel = accountViewModel) - return - } - // After a confirmed scan the user sees only the pool stay screen, until they logged a distance. // It is derived from the stored entry, so reopening the app later lands here again. val poolStayViewModel: PoolStayViewModel = viewModel() val poolStay by poolStayViewModel.state.collectAsState() - when (poolStay) { - PoolStayState.Loading -> return - PoolStayState.None -> Unit - is PoolStayState.CancelWindow, - is PoolStayState.LogRequired -> { + + when ( + appGate( + updateAvailable = updateState !is UpdateUiState.None, + signedIn = user != null && !user.isAnonymous, + poolStay = poolStay, + ) + ) { + AppGate.Loading -> return + AppGate.PoolStay -> { PoolStayScreen(poolStayViewModel) return } + AppGate.Update -> { + UpdateRequiredScreen(updateViewModel) + return + } + AppGate.SignIn -> { + SignInScreen(viewModel = accountViewModel) + return + } + AppGate.App -> Unit } val navController: NavHostController = rememberNavController() diff --git a/app/src/test/java/com/github/se/pooltrack/AppGateTest.kt b/app/src/test/java/com/github/se/pooltrack/AppGateTest.kt new file mode 100644 index 0000000..48a0fa8 --- /dev/null +++ b/app/src/test/java/com/github/se/pooltrack/AppGateTest.kt @@ -0,0 +1,80 @@ +package com.github.se.pooltrack + +import com.github.se.pooltrack.model.entry.Entry +import com.github.se.pooltrack.ui.poolstay.PoolStayState +import java.time.Duration +import org.junit.Assert.assertEquals +import org.junit.Test + +class AppGateTest { + + private val entry = Entry(timestampEpochMilli = 0L, awaitingDistance = true) + private val cancelWindow = + PoolStayState.CancelWindow(entry, Duration.ofMinutes(2), Duration.ofMinutes(8)) + private val logRequired = PoolStayState.LogRequired(entry) + + private fun gate(update: Boolean, signedIn: Boolean, poolStay: PoolStayState) = + appGate(updateAvailable = update, signedIn = signedIn, poolStay = poolStay) + + @Test + fun aSwimWaitingForItsDistance_comesBeforeTheUpdate() { + assertEquals(AppGate.PoolStay, gate(update = true, signedIn = true, poolStay = logRequired)) + assertEquals(AppGate.PoolStay, gate(update = true, signedIn = true, poolStay = cancelWindow)) + } + + @Test + fun aSwimWaitingForItsDistance_isShown_withoutAnUpdate() { + assertEquals(AppGate.PoolStay, gate(update = false, signedIn = true, poolStay = logRequired)) + } + + @Test + fun theUpdate_isShown_onceNoSwimIsWaiting() { + assertEquals( + AppGate.Update, + gate(update = true, signedIn = true, poolStay = PoolStayState.None), + ) + } + + @Test + fun nothingIsShown_whileItIsUnknownWhetherASwimIsWaiting() { + // So the update never flashes up for a moment before the swim screen takes over. + assertEquals( + AppGate.Loading, + gate(update = true, signedIn = true, poolStay = PoolStayState.Loading), + ) + assertEquals( + AppGate.Loading, + gate(update = false, signedIn = true, poolStay = PoolStayState.Loading), + ) + } + + @Test + fun theUpdate_isShown_toSomeoneNotSignedIn() { + assertEquals( + AppGate.Update, + gate(update = true, signedIn = false, poolStay = PoolStayState.None), + ) + assertEquals( + AppGate.Update, + gate(update = true, signedIn = false, poolStay = PoolStayState.Loading), + ) + } + + @Test + fun signIn_isShown_whenNobodyIsSignedIn_andNoUpdateIsNeeded() { + assertEquals( + AppGate.SignIn, + gate(update = false, signedIn = false, poolStay = PoolStayState.None), + ) + } + + @Test + fun aSwimOfAnotherAccount_isIgnored_whenNobodyIsSignedIn() { + assertEquals(AppGate.SignIn, gate(update = false, signedIn = false, poolStay = logRequired)) + } + + @Test + fun theNormalApp_isShown_whenNothingTakesOver() { + assertEquals(AppGate.App, gate(update = false, signedIn = true, poolStay = PoolStayState.None)) + } +}