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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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,
)
}

Expand Down Expand Up @@ -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 }
}
Expand Down
13 changes: 13 additions & 0 deletions app/src/main/java/com/mapgie/dash/ui/navigation/DashNavGraph.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -123,6 +124,7 @@ fun DashNavGraph(
var pendingSettingsSubScreen by remember { mutableStateOf<SettingsSubScreen?>(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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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<Boolean> = _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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 ->
Expand All @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -153,29 +166,45 @@ fun ChangelogDialog(
entries: List<ChangelogEntry>,
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)
}
}
}
Expand All @@ -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,
)
}
Loading
Loading