diff --git a/app/src/main/java/com/mapgie/dash/data/preferences/SettingsRepository.kt b/app/src/main/java/com/mapgie/dash/data/preferences/SettingsRepository.kt index 654ddb9..94b4e68 100644 --- a/app/src/main/java/com/mapgie/dash/data/preferences/SettingsRepository.kt +++ b/app/src/main/java/com/mapgie/dash/data/preferences/SettingsRepository.kt @@ -93,6 +93,8 @@ data class AppSettings( val swipeActions: SwipeSettings = SwipeSettings(), // Whether the first-run welcome sheet (chores vs tasks vs memos) has been dismissed val helpSeen: Boolean = false, + // The app versionCode last opened; drives the "What's New" prompt on update (0 = never recorded) + val lastSeenVersionCode: Int = 0, ) @Singleton @@ -106,6 +108,7 @@ class SettingsRepository @Inject constructor( val THEME_MODE = stringPreferencesKey("theme_mode") val WCAG_MODE = booleanPreferencesKey("wcag_mode") val HELP_SEEN = booleanPreferencesKey("help_seen") + val LAST_SEEN_VERSION_CODE = intPreferencesKey("last_seen_version_code") val ZEN_MODE = booleanPreferencesKey("zen_mode") val TASK_ZEN_MODE = booleanPreferencesKey("task_zen_mode") val DELIVERY_MODE = stringPreferencesKey("delivery_mode") @@ -238,6 +241,7 @@ class SettingsRepository @Inject constructor( memos = readSwipePair(prefs, SwipeSubject.MEMOS), ), helpSeen = prefs[Keys.HELP_SEEN] ?: false, + lastSeenVersionCode = prefs[Keys.LAST_SEEN_VERSION_CODE] ?: 0, ) } @@ -270,6 +274,10 @@ class SettingsRepository @Inject constructor( context.dataStore.edit { it[Keys.HELP_SEEN] = seen } } + suspend fun setLastSeenVersionCode(code: Int) { + context.dataStore.edit { it[Keys.LAST_SEEN_VERSION_CODE] = code } + } + suspend fun setZenMode(enabled: Boolean) { context.dataStore.edit { it[Keys.ZEN_MODE] = enabled } } diff --git a/app/src/main/java/com/mapgie/dash/ui/navigation/DashNavGraph.kt b/app/src/main/java/com/mapgie/dash/ui/navigation/DashNavGraph.kt index b4c5899..afae7eb 100644 --- a/app/src/main/java/com/mapgie/dash/ui/navigation/DashNavGraph.kt +++ b/app/src/main/java/com/mapgie/dash/ui/navigation/DashNavGraph.kt @@ -34,6 +34,7 @@ import com.mapgie.dash.ui.components.AddMenuButton import com.mapgie.dash.ui.components.SpeedDialOverlay import com.mapgie.dash.ui.components.TagAlarmConflictDialog import com.mapgie.dash.ui.components.WelcomeSheet +import com.mapgie.dash.ui.screens.settings.WhatsNewDialog import com.mapgie.dash.ui.components.core.LocalReminderLabel import com.mapgie.dash.ui.screens.chores.ChoreListScreen import com.mapgie.dash.ui.screens.licenses.LicensesScreen @@ -123,6 +124,7 @@ fun DashNavGraph( var pendingSettingsSubScreen by remember { mutableStateOf(null) } val navUiState by navViewModel.uiState.collectAsStateWithLifecycle() + val showWhatsNew by navViewModel.showWhatsNew.collectAsStateWithLifecycle() // The Memos/Reminders slot is always present, so the five-slot bar never // reshapes under the thumb (handoff: fixed Tasks · Chores · + · Memos · Settings). val navItems = allNavItems @@ -337,6 +339,17 @@ fun DashNavGraph( reminderLabel = navUiState.reminderLabel.displayName, onDismiss = { navViewModel.markWelcomeSeen() }, ) + } else if (showWhatsNew) { + // After an in-place update: the changelog for the new version, + // with a way through to the how-to-use pages. + WhatsNewDialog( + onDismiss = { navViewModel.dismissWhatsNew() }, + onOpenHelp = { + navViewModel.dismissWhatsNew() + pendingSettingsSubScreen = SettingsSubScreen.HELP + navigateTo(Screen.Settings.route) + }, + ) } // The speed dial covers the whole screen, bar included, so it sits diff --git a/app/src/main/java/com/mapgie/dash/ui/navigation/DashNavViewModel.kt b/app/src/main/java/com/mapgie/dash/ui/navigation/DashNavViewModel.kt index 722e046..09d1c10 100644 --- a/app/src/main/java/com/mapgie/dash/ui/navigation/DashNavViewModel.kt +++ b/app/src/main/java/com/mapgie/dash/ui/navigation/DashNavViewModel.kt @@ -2,15 +2,19 @@ package com.mapgie.dash.ui.navigation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.mapgie.dash.BuildConfig import com.mapgie.dash.data.model.AddMenuOption import com.mapgie.dash.data.model.ReminderLabelStyle import com.mapgie.dash.data.preferences.DEFAULT_FAB_ORDER import com.mapgie.dash.data.preferences.SettingsRepository import com.mapgie.dash.data.repository.ReminderRepository import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -27,6 +31,15 @@ data class DashNavUiState( val showWelcome: Boolean = false, ) +/** + * Whether to show the "What's New" changelog on open. True only when the app has + * been updated in place from a previously recorded version: a fresh install + * ([lastSeen] == 0) gets the welcome sheet instead, and a same or older version + * shows nothing. Pure so the rule is covered by [UpdateGateTest]. + */ +internal fun showWhatsNewOnUpdate(lastSeen: Int, current: Int): Boolean = + lastSeen in 1 until current + @HiltViewModel class DashNavViewModel @Inject constructor( reminderRepository: ReminderRepository, @@ -45,7 +58,29 @@ class DashNavViewModel @Inject constructor( ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DashNavUiState()) + private val _showWhatsNew = MutableStateFlow(false) + /** One-shot: true once per version bump, until dismissed. */ + val showWhatsNew: StateFlow = _showWhatsNew.asStateFlow() + + init { + // Read the last-opened version once, decide whether this launch is an + // update, then record the current version so it fires only once. + viewModelScope.launch { + val lastSeen = settingsRepository.settings.first().lastSeenVersionCode + if (showWhatsNewOnUpdate(lastSeen, BuildConfig.VERSION_CODE)) { + _showWhatsNew.value = true + } + if (lastSeen != BuildConfig.VERSION_CODE) { + settingsRepository.setLastSeenVersionCode(BuildConfig.VERSION_CODE) + } + } + } + fun markWelcomeSeen() { viewModelScope.launch { settingsRepository.setHelpSeen(true) } } + + fun dismissWhatsNew() { + _showWhatsNew.value = false + } } diff --git a/app/src/main/java/com/mapgie/dash/ui/screens/settings/HelpSubScreen.kt b/app/src/main/java/com/mapgie/dash/ui/screens/settings/HelpSubScreen.kt index db498b1..a151a80 100644 --- a/app/src/main/java/com/mapgie/dash/ui/screens/settings/HelpSubScreen.kt +++ b/app/src/main/java/com/mapgie/dash/ui/screens/settings/HelpSubScreen.kt @@ -2,6 +2,7 @@ package com.mapgie.dash.ui.screens.settings import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -12,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.verticalScroll @@ -22,6 +24,8 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -34,6 +38,8 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.mapgie.dash.BuildConfig import com.mapgie.dash.data.model.ReminderLabelStyle import com.mapgie.dash.ui.components.HelpContent import com.mapgie.dash.ui.components.HelpGettingAround @@ -52,6 +58,7 @@ internal fun HelpSubScreen( val settings by viewModel.settings.collectAsState() val reminderLabel = (settings?.reminderLabel ?: ReminderLabelStyle.REMINDERS).displayName var page by rememberSaveable { mutableIntStateOf(0) } + var showChangelog by remember { mutableStateOf(false) } val tabs = listOf("What goes where", "Getting around") SettingsSubScreenScaffold(title = "Help", onBack = onBack) { innerPadding -> @@ -63,23 +70,59 @@ internal fun HelpSubScreen( ) { HelpPageTabs(selected = page, labels = tabs, onSelect = { page = it }) Spacer(Modifier.height(14.dp)) - // A fresh scroll state per page so each opens at the top. - key(page) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - when (page) { - 0 -> HelpContent(reminderLabel = reminderLabel) - else -> HelpGettingAround(reminderLabel = reminderLabel) + // The pages scroll; the version footer stays pinned below them. + Box(modifier = Modifier.weight(1f)) { + // A fresh scroll state per page so each opens at the top. + key(page) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + when (page) { + 0 -> HelpContent(reminderLabel = reminderLabel) + else -> HelpGettingAround(reminderLabel = reminderLabel) + } + Spacer(Modifier.height(8.dp)) } - Spacer(Modifier.height(8.dp)) } } + HelpVersionFooter(onClick = { showChangelog = true }) } } + + if (showChangelog) { + WhatsNewDialog(onDismiss = { showChangelog = false }) + } +} + +/** Tappable "Version x.y.z" line at the foot of Help; opens What's New. */ +@Composable +private fun HelpVersionFooter(onClick: () -> Unit) { + val tokens = LocalDashTokens.current + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(1.dp), + modifier = Modifier + .fillMaxWidth() + .semantics { role = Role.Button } + .clickable(onClick = onClick) + .heightIn(min = 44.dp) + .padding(vertical = 8.dp) + .wrapContentHeight(Alignment.CenterVertically), + ) { + Text( + "Version ${BuildConfig.VERSION_NAME}", + style = MaterialTheme.typography.bodySmall.copy(fontSize = 13.5.sp, fontWeight = FontWeight.Bold), + color = tokens.inkFaint, + ) + Text( + "What's new ›", + style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.ExtraBold), + color = MaterialTheme.colorScheme.primary, + ) + } } /** Segmented header, two full-width cells; mirrors the zen "mine | all" pill. */ diff --git a/app/src/main/java/com/mapgie/dash/ui/screens/settings/SettingsComponents.kt b/app/src/main/java/com/mapgie/dash/ui/screens/settings/SettingsComponents.kt index 6ddb5b4..07b1005 100644 --- a/app/src/main/java/com/mapgie/dash/ui/screens/settings/SettingsComponents.kt +++ b/app/src/main/java/com/mapgie/dash/ui/screens/settings/SettingsComponents.kt @@ -1,5 +1,8 @@ package com.mapgie.dash.ui.screens.settings +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -15,12 +18,22 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import java.io.BufferedReader +import java.io.InputStreamReader + +/** Where "View full changelog" sends the user: the repo's CHANGELOG.md. */ +const val CHANGELOG_URL = "https://github.com/mapgie/choreDash-Android/blob/main/CHANGELOG.md" /** * Wraps a settings sub-screen in a Scaffold whose top bar is the 4a header: @@ -153,29 +166,45 @@ fun ChangelogDialog( entries: List, onDismiss: () -> Unit, onViewFullChangelog: () -> Unit, + onOpenHelp: (() -> Unit)? = null, ) { AlertDialog( onDismissRequest = onDismiss, title = { Text("What's New") }, text = { - if (entries.isEmpty()) { - Text("No changelog available.") - } else { - Column( - modifier = Modifier - .fillMaxHeight(0.7f) - .verticalScroll(rememberScrollState()) - ) { - entries.forEachIndexed { index, entry -> - if (index > 0) { - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Column(modifier = Modifier.fillMaxHeight(0.7f)) { + if (onOpenHelp != null) { + Text( + "How to use the app ›", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .fillMaxWidth() + .semantics { role = Role.Button } + .clickable(onClick = onOpenHelp) + .padding(vertical = 10.dp), + ) + HorizontalDivider(modifier = Modifier.padding(bottom = 8.dp)) + } + if (entries.isEmpty()) { + Text("No changelog available.") + } else { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + ) { + entries.forEachIndexed { index, entry -> + if (index > 0) { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + } + Text( + entry.header, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary + ) + ChangelogBody(entry.body) } - Text( - entry.header, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary - ) - ChangelogBody(entry.body) } } } @@ -188,3 +217,30 @@ fun ChangelogDialog( } ) } + +/** + * The "What's New" dialog wired to `assets/CHANGELOG.md` and the full-changelog + * link. One place so Settings › About, Help, and the on-update prompt share it. + */ +@Composable +fun WhatsNewDialog( + onDismiss: () -> Unit, + onOpenHelp: (() -> Unit)? = null, +) { + val context = LocalContext.current + val entries = remember { + runCatching { + context.assets.open("CHANGELOG.md").use { input -> + BufferedReader(InputStreamReader(input)).readText() + } + }.map(::parseChangelog).getOrDefault(emptyList()) + } + ChangelogDialog( + entries = entries, + onDismiss = onDismiss, + onViewFullChangelog = { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(CHANGELOG_URL))) + }, + onOpenHelp = onOpenHelp, + ) +} diff --git a/app/src/main/java/com/mapgie/dash/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/mapgie/dash/ui/screens/settings/SettingsScreen.kt index 4fae8e5..812e385 100644 --- a/app/src/main/java/com/mapgie/dash/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/mapgie/dash/ui/screens/settings/SettingsScreen.kt @@ -103,8 +103,6 @@ enum class SettingsSubScreen { NONE, CONNECTION, APPEARANCE, COLOURS, CATEGORIES, DISPLAY, QUICK_ADD, SWIPE, REMINDERS, WIDGET, TAGS, ABOUT, HELP } -private const val CHANGELOG_URL = "https://github.com/mapgie/choreDash-Android/blob/main/CHANGELOG.md" - /** Content inset for every settings page (18dp per the 3a/4a mock-ups). */ private val PageInset = 18.dp @@ -201,6 +199,7 @@ fun SettingsScreen( SettingsSubScreen.ABOUT -> AboutSubScreen( onBack = { subScreen = SettingsSubScreen.NONE }, onNavigateToLicenses = onNavigateToLicenses, + onOpenHelp = { subScreen = SettingsSubScreen.HELP }, ) SettingsSubScreen.HELP -> HelpSubScreen( onBack = { subScreen = SettingsSubScreen.NONE }, @@ -1198,8 +1197,8 @@ private fun WidgetSubScreen( private fun AboutSubScreen( onBack: () -> Unit, onNavigateToLicenses: () -> Unit, + onOpenHelp: () -> Unit, ) { - val context = LocalContext.current var showChangelog by remember { mutableStateOf(false) } SettingsSubScreenScaffold(title = "About", onBack = onBack) { innerPadding -> @@ -1265,20 +1264,12 @@ private fun AboutSubScreen( } if (showChangelog) { - val entries = remember { - runCatching { - val text = context.assets.open("CHANGELOG.md").use { input -> - BufferedReader(InputStreamReader(input)).readText() - } - parseChangelog(text) - }.getOrDefault(emptyList()) - } - ChangelogDialog( - entries = entries, + WhatsNewDialog( onDismiss = { showChangelog = false }, - onViewFullChangelog = { - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(CHANGELOG_URL))) - } + onOpenHelp = { + showChangelog = false + onOpenHelp() + }, ) } } diff --git a/app/src/test/java/com/mapgie/dash/ui/navigation/UpdateGateTest.kt b/app/src/test/java/com/mapgie/dash/ui/navigation/UpdateGateTest.kt new file mode 100644 index 0000000..7bd0572 --- /dev/null +++ b/app/src/test/java/com/mapgie/dash/ui/navigation/UpdateGateTest.kt @@ -0,0 +1,39 @@ +package com.mapgie.dash.ui.navigation + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * When the "What's New" changelog shows on open. Only a genuine in-place update + * from a previously recorded version qualifies: a fresh install (last seen 0) + * gets the welcome sheet instead, and the same or an older version shows nothing. + * Pure logic, no Android involved. + */ +class UpdateGateTest { + + @Test + fun `fresh install does not show whats new`() { + assertFalse(showWhatsNewOnUpdate(lastSeen = 0, current = 60)) + } + + @Test + fun `update from an earlier version shows whats new`() { + assertTrue(showWhatsNewOnUpdate(lastSeen = 59, current = 60)) + } + + @Test + fun `reopening the same version shows nothing`() { + assertFalse(showWhatsNewOnUpdate(lastSeen = 60, current = 60)) + } + + @Test + fun `a downgrade shows nothing`() { + assertFalse(showWhatsNewOnUpdate(lastSeen = 61, current = 60)) + } + + @Test + fun `a jump across several versions still shows whats new`() { + assertTrue(showWhatsNewOnUpdate(lastSeen = 40, current = 60)) + } +} diff --git a/changelog/unreleased/whats-new-on-update.json b/changelog/unreleased/whats-new-on-update.json new file mode 100644 index 0000000..222b849 --- /dev/null +++ b/changelog/unreleased/whats-new-on-update.json @@ -0,0 +1,8 @@ +{ + "bump": "minor", + "added": [ + "After an update, the What's New changelog now shows on open so you can see what changed.", + "The Help screen shows the app version at the foot; tap it to open What's New.", + "What's New links through to the how-to-use Help pages." + ] +}