Skip to content

Commit 7d08dd0

Browse files
authored
Merge pull request #110 from mapgie/claude/new-session-demhke
Finish the handoff: legacy settings screens, zen list, reminder view, sheet drafts, welcome and Help
2 parents 2363fc5 + d4c77cb commit 7d08dd0

41 files changed

Lines changed: 3524 additions & 1030 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

LESSONS.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -864,3 +864,73 @@ again. Remove the key, the `AppSettings` field, the setter and the ViewModel
864864
plumbing in the same PR, and say in the changelog which control absorbed the
865865
behaviour ("folded into the sort pill"). Leaving the stored value on users'
866866
devices is harmless; DataStore ignores unknown keys.
867+
868+
---
869+
870+
## 41. "Superseded where they conflict" means the older artboards still apply
871+
872+
The consolidated handoff said turns 4a and earlier were "legacy; superseded
873+
where they conflict with the above". The first pass read that as "ignore 4a"
874+
and shipped the 5a to 8a screens only, so Settings, Appearance, Display,
875+
Reminders & alerts, About, the zen list, the search row and the reminder view
876+
kept their Material defaults and the user rightly said the design was not all
877+
implemented. A superseding turn only replaces what it redraws. Audit every
878+
artboard in the package against the code (list them from the HTML comments,
879+
`<!-- ░░ 4A DISPLAY ░░ -->`, and tick each one off), and put anything that is
880+
skipped in the PR's "Dropped / needs decision" list, never in silence.
881+
882+
---
883+
884+
## 42. When a design removes hint copy, the surface it points to must ship in the same PR
885+
886+
7a took the explanatory text off the speed dial because "the chore/task
887+
distinction is taught once in the first-run splash and repeated under
888+
Settings › Help". Neither existed, so removing the hint left nothing teaching
889+
the distinction at all. A rule that moves copy elsewhere is a requirement for
890+
the elsewhere: build the welcome sheet and the Help page (or keep the hint)
891+
in the same change. `HelpContent` renders both from one composable so the
892+
two never drift.
893+
894+
---
895+
896+
## 43. Deep links from a notification tap: state on the Activity, navigate from the graph, strip the extras
897+
898+
A notification's content intent lands in `MainActivity` via `onCreate` on a
899+
cold start or `onNewIntent` when the app is already up, and the nav
900+
controller lives inside `setContent`. The pattern that works for both paths is
901+
the one NFC and the widgets already use: read the extras into a Compose state
902+
on the Activity (`pendingReminderView`), hand it to `DashNavGraph`, navigate
903+
from a `LaunchedEffect` keyed on it, and call back to clear it. Two details
904+
bite: `onNewIntent` must call `setIntent(intent)` or a later rotation replays
905+
the old intent, and the "consumed" callback should `removeExtra` the keys from
906+
the Activity intent so a configuration change does not reopen the screen.
907+
Use a plain `navController.navigate` for these one-off screens, not the tab
908+
helper with `popUpTo`/`restoreState`, so Back returns to whatever tab was
909+
showing.
910+
911+
---
912+
913+
## 44. Offer a stored draft once, at open, and snapshot the offer in rememberSaveable
914+
915+
The Edit sheets keep every field in `rememberSaveable` and mirror the current
916+
values into a `DraftStore` (a JSON map in the ViewModel's `SavedStateHandle`)
917+
whenever the sheet is dirty. Two traps when the sheet then reads that store
918+
back on open:
919+
920+
1. Do not decide "is there a draft to offer?" on every composition. After a
921+
rotation the fields already carry the edits (restored by `rememberSaveable`)
922+
and the store holds the same values, so a live check would offer the user
923+
their own current text. Capture the offer once in
924+
`rememberSaveable(stateSaver = jsonStateSaver(...)) { draft?.takeIf { it.differsFrom(opened) } }`
925+
and only clear it on Restore or Forget.
926+
2. Keep the offered draft separate from the store while it is pending. Writing
927+
the fields into the store on every change would overwrite the old draft on
928+
the first keystroke, before the user could restore it. A clean dismiss with
929+
an unanswered offer writes the offered draft back; Save, Discard, Delete and
930+
Archive clear the store.
931+
932+
The same change moved the screens' sheet target from a remembered object
933+
(`Chore?` / `TaskDto?`) to a `rememberSaveable` id resolved from `uiState`.
934+
Wait for `uiState.loading` to finish before treating a missing id as "vanished",
935+
or a process-death restore closes the sheet (or opens it as "New") before the
936+
list has loaded.

app/src/main/java/com/mapgie/dash/MainActivity.kt

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ import com.mapgie.dash.data.preferences.ThemeMode
3030
import com.mapgie.dash.data.repository.ChoreRepository
3131
import com.mapgie.dash.nfc.NfcHandler
3232
import com.mapgie.dash.nfc.NfcWriteResult
33+
import com.mapgie.dash.notification.NotificationHelper
3334
import com.mapgie.dash.ui.navigation.DashNavGraph
35+
import com.mapgie.dash.ui.screens.reminder.ReminderViewKind
3436
import com.mapgie.dash.ui.theme.AppTheme
3537
import com.mapgie.dash.ui.theme.CustomHSL
3638
import com.mapgie.dash.ui.theme.DashTheme
@@ -56,6 +58,10 @@ class MainActivity : ComponentActivity() {
5658
// Compose state: set when launched from a home screen widget, drives navigation
5759
private var pendingWidgetDestination by mutableStateOf<String?>(null)
5860

61+
// Compose state: set when launched from a reminder notification, as
62+
// (route kind, record id); drives navigation to the full-screen reminder view.
63+
private var pendingReminderView by mutableStateOf<Pair<String, String>?>(null)
64+
5965
// When set, the next scanned tag is written with this chore tag ID instead of being read
6066
private var nfcWriteRequest by mutableStateOf<String?>(null)
6167
private var nfcWriteResult by mutableStateOf<NfcWriteResult?>(null)
@@ -84,6 +90,7 @@ class MainActivity : ComponentActivity() {
8490
)
8591

8692
handleNfcIntent(intent, fromForeground = false)
93+
handleReminderIntent(intent)
8794

8895
setContent {
8996
val settings by settingsRepository.settings.collectAsState(initial = null)
@@ -132,6 +139,14 @@ class MainActivity : ComponentActivity() {
132139
onNfcConsumed = { pendingNfcTagId = null },
133140
pendingWidgetDestination = pendingWidgetDestination,
134141
onWidgetDestinationConsumed = { pendingWidgetDestination = null },
142+
pendingReminderView = pendingReminderView,
143+
onReminderViewConsumed = {
144+
pendingReminderView = null
145+
// Strip the extras so a configuration change does not
146+
// re-deliver the launch intent and reopen the view.
147+
intent?.removeExtra(NotificationHelper.EXTRA_REMINDER_ID)
148+
intent?.removeExtra(NotificationHelper.EXTRA_TASK_ID)
149+
},
135150
nfcWriteRequest = nfcWriteRequest,
136151
nfcWriteResult = nfcWriteResult,
137152
onStartNfcWrite = { tagId ->
@@ -154,7 +169,23 @@ class MainActivity : ComponentActivity() {
154169

155170
override fun onNewIntent(intent: Intent) {
156171
super.onNewIntent(intent)
172+
setIntent(intent)
157173
handleNfcIntent(intent, fromForeground = isActivityResumed)
174+
handleReminderIntent(intent)
175+
}
176+
177+
// Reminder notifications open MainActivity with EXTRA_REMINDER_ID (standalone
178+
// reminder) or EXTRA_TASK_ID (a task's own reminder). Reminder wins when both
179+
// are present, matching AlarmReceiver.
180+
private fun handleReminderIntent(intent: Intent?) {
181+
if (intent == null) return
182+
val reminderId = intent.getStringExtra(NotificationHelper.EXTRA_REMINDER_ID)
183+
val taskId = intent.getStringExtra(NotificationHelper.EXTRA_TASK_ID)
184+
pendingReminderView = when {
185+
!reminderId.isNullOrBlank() -> ReminderViewKind.REMINDER.routeArg to reminderId
186+
!taskId.isNullOrBlank() -> ReminderViewKind.TASK.routeArg to taskId
187+
else -> return
188+
}
158189
}
159190

160191
private fun handleNfcIntent(intent: Intent, fromForeground: Boolean) {
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package com.mapgie.dash.data.model
2+
3+
import androidx.lifecycle.SavedStateHandle
4+
import kotlinx.coroutines.flow.MutableStateFlow
5+
import kotlinx.coroutines.flow.StateFlow
6+
import kotlinx.coroutines.flow.asStateFlow
7+
import kotlinx.serialization.KSerializer
8+
import kotlinx.serialization.builtins.MapSerializer
9+
import kotlinx.serialization.builtins.serializer
10+
import kotlinx.serialization.json.Json
11+
12+
/**
13+
* Unsaved Edit sheet drafts keyed by item id (or [NEW_DRAFT_KEY]), mirrored
14+
* into a [SavedStateHandle] as one JSON string under [key] so they survive
15+
* rotation and process death for as long as the session does. Owned by the
16+
* list ViewModels; the sheets never apply a stored draft on their own, they
17+
* offer it (see the design handoff, "Sheet dismissal & unsaved changes").
18+
*/
19+
class DraftStore<T>(
20+
private val handle: SavedStateHandle,
21+
private val key: String,
22+
valueSerializer: KSerializer<T>,
23+
) {
24+
private val serializer = MapSerializer(String.serializer(), valueSerializer)
25+
private val _drafts = MutableStateFlow(decode(handle.get<String>(key)))
26+
27+
/** Every draft currently held, keyed by item id. */
28+
val drafts: StateFlow<Map<String, T>> = _drafts.asStateFlow()
29+
30+
fun get(id: String): T? = _drafts.value[id]
31+
32+
fun put(id: String, draft: T) {
33+
if (_drafts.value[id] == draft) return
34+
write(_drafts.value + (id to draft))
35+
}
36+
37+
fun clear(id: String) {
38+
if (id !in _drafts.value) return
39+
write(_drafts.value - id)
40+
}
41+
42+
private fun write(next: Map<String, T>) {
43+
_drafts.value = next
44+
handle[key] = json.encodeToString(serializer, next)
45+
}
46+
47+
private fun decode(raw: String?): Map<String, T> =
48+
raw?.let { runCatching { json.decodeFromString(serializer, it) }.getOrNull() } ?: emptyMap()
49+
50+
private companion object {
51+
val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
52+
}
53+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package com.mapgie.dash.data.model
2+
3+
import kotlinx.serialization.Serializable
4+
import java.time.Instant
5+
import java.time.LocalDate
6+
import java.time.temporal.ChronoUnit
7+
8+
/** Draft key for the New chore / New task sheets, which have no item id yet. */
9+
const val NEW_DRAFT_KEY = "new"
10+
11+
/** The draft key for an Edit sheet: the item's id, or [NEW_DRAFT_KEY] while creating. */
12+
fun draftKeyFor(itemId: String?): String = itemId ?: NEW_DRAFT_KEY
13+
14+
/**
15+
* Everything the Edit chore sheet can change, as plain values so it can be
16+
* saved through rotation and process death and offered back on reopen.
17+
* Blank strings stand for "none" the way the sheet fields do.
18+
*/
19+
@Serializable
20+
data class ChoreDraft(
21+
val label: String = "",
22+
val category: String = "",
23+
val owner: String = "",
24+
val intervalDays: Int? = null,
25+
val tagId: String = "",
26+
) {
27+
/** True when any field differs from [opened], the values the sheet started with. */
28+
fun differsFrom(opened: ChoreDraft): Boolean =
29+
label != opened.label ||
30+
category != opened.category ||
31+
owner != opened.owner ||
32+
intervalDays != opened.intervalDays ||
33+
tagId != opened.tagId
34+
35+
/** The name to say when offering this draft back: the title typed so far, if any. */
36+
fun displayName(): String? = label.trim().ifBlank { null }
37+
38+
companion object {
39+
/**
40+
* The values the sheet opens with: [chore]'s own fields, or the New chore
41+
* defaults (General, [initialTagId] from an NFC scan) when [chore] is null.
42+
*/
43+
fun of(chore: Chore?, initialTagId: String = ""): ChoreDraft =
44+
if (chore == null) {
45+
ChoreDraft(category = GENERAL_CATEGORY, tagId = initialTagId)
46+
} else {
47+
ChoreDraft(
48+
label = chore.label,
49+
category = chore.category ?: "",
50+
owner = chore.owner ?: "",
51+
intervalDays = chore.intervalDays?.toInt(),
52+
tagId = chore.tagId,
53+
)
54+
}
55+
}
56+
}
57+
58+
/** The three shapes of a task's Due row, stored by name in a [TaskDraft]. */
59+
object TaskDueType {
60+
const val NONE = "none"
61+
const val DATE = "date"
62+
const val PERIOD = "period"
63+
}
64+
65+
/**
66+
* Everything the Edit task sheet can change, as plain values. Dates are epoch
67+
* days, the reminder is epoch millis at whole-minute precision and is null
68+
* whenever the reminder is off, so two drafts compare equal when the sheet
69+
* would show the same thing.
70+
*/
71+
@Serializable
72+
data class TaskDraft(
73+
val title: String = "",
74+
val notes: String = "",
75+
val category: String = "",
76+
val owner: String = "",
77+
val priority: String = TaskPriority.NORMAL.name,
78+
val dueType: String = TaskDueType.NONE,
79+
val dueDateEpochDay: Long? = null,
80+
val duePeriod: String = "today",
81+
val reminderEnabled: Boolean = false,
82+
val reminderAtEpochMillis: Long? = null,
83+
) {
84+
/** True when any field differs from [opened], the values the sheet started with. */
85+
fun differsFrom(opened: TaskDraft): Boolean = this != opened
86+
87+
/** The name to say when offering this draft back: the title typed so far, if any. */
88+
fun displayName(): String? = title.trim().ifBlank { null }
89+
90+
fun priorityEnum(): TaskPriority =
91+
runCatching { TaskPriority.valueOf(priority) }.getOrDefault(TaskPriority.NORMAL)
92+
93+
fun dueDate(): LocalDate? = dueDateEpochDay?.let { LocalDate.ofEpochDay(it) }
94+
95+
companion object {
96+
/**
97+
* The values the sheet opens with: [task]'s own fields, or the New task
98+
* defaults (General, normal priority, no due, no reminder) when null.
99+
*/
100+
fun of(task: TaskDto?): TaskDraft {
101+
if (task == null) return TaskDraft(category = GENERAL_CATEGORY)
102+
return TaskDraft(
103+
title = task.title,
104+
notes = task.notes ?: "",
105+
category = task.category ?: "",
106+
owner = task.owner ?: "",
107+
priority = task.priorityEnum().name,
108+
dueType = when {
109+
task.dueDate != null -> TaskDueType.DATE
110+
task.duePeriod != null -> TaskDueType.PERIOD
111+
else -> TaskDueType.NONE
112+
},
113+
dueDateEpochDay = task.dueDate?.let { runCatching { LocalDate.parse(it).toEpochDay() }.getOrNull() },
114+
duePeriod = task.duePeriod ?: "today",
115+
reminderEnabled = task.reminderAt != null,
116+
// Whole minutes, matching what the sheet resolves on save, so a stored
117+
// time with seconds does not look changed the moment the sheet opens.
118+
reminderAtEpochMillis = task.reminderAt?.let {
119+
runCatching { Instant.parse(it).truncatedTo(ChronoUnit.MINUTES).toEpochMilli() }.getOrNull()
120+
},
121+
)
122+
}
123+
}
124+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package com.mapgie.dash.data.model
2+
3+
/**
4+
* The gentle sub-line under a zen row (handoff 3a-4): "kitchen · when you're
5+
* up", "admin · whenever", "outdoor · done, nice". Zen shows no pressure
6+
* colours and no counts, so the state is carried by soft words instead.
7+
*/
8+
object ZenPhrase {
9+
const val DONE = "done, nice"
10+
11+
/** The soft cue for a chore in [status]; [done] wins once it was logged in this zen session. */
12+
fun forChore(category: String?, status: ChoreStatus, done: Boolean): String =
13+
join(category, if (done) DONE else choreCue(status))
14+
15+
/** The soft cue for a task by its [urgency] and [priority]; [done] wins once it was ticked. */
16+
fun forTask(category: String?, urgency: TaskUrgency, priority: TaskPriority, done: Boolean): String =
17+
join(category, if (done) DONE else taskCue(urgency, priority))
18+
19+
fun choreCue(status: ChoreStatus): String = when (status) {
20+
ChoreStatus.STALE -> "when you're up"
21+
ChoreStatus.AGING -> "this week"
22+
ChoreStatus.FRESH -> "anytime"
23+
ChoreStatus.NEVER -> "whenever"
24+
}
25+
26+
fun taskCue(urgency: TaskUrgency, priority: TaskPriority): String = when (urgency) {
27+
TaskUrgency.OVERDUE, TaskUrgency.TODAY -> "when you're up"
28+
TaskUrgency.THIS_WEEK -> "this week"
29+
TaskUrgency.LATER -> "whenever"
30+
TaskUrgency.NONE -> if (priority == TaskPriority.HIGHER) "when you're up" else "anytime"
31+
}
32+
33+
private fun join(category: String?, cue: String): String {
34+
val cat = category?.trim()?.takeIf { it.isNotEmpty() }?.lowercase()
35+
return if (cat == null) cue else "$cat · $cue"
36+
}
37+
}

app/src/main/java/com/mapgie/dash/data/preferences/SettingsRepository.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ data class AppSettings(
7979
val fabOrder: List<AddMenuOption> = DEFAULT_FAB_ORDER,
8080
// Wording used for the reminders feature throughout the UI
8181
val reminderLabel: ReminderLabelStyle = ReminderLabelStyle.REMINDERS,
82+
// Whether the first-run welcome sheet (chores vs tasks vs memos) has been dismissed
83+
val helpSeen: Boolean = false,
8284
)
8385

8486
@Singleton
@@ -91,6 +93,7 @@ class SettingsRepository @Inject constructor(
9193
val OWNER_HANDLE = stringPreferencesKey("owner_handle")
9294
val THEME_MODE = stringPreferencesKey("theme_mode")
9395
val WCAG_MODE = booleanPreferencesKey("wcag_mode")
96+
val HELP_SEEN = booleanPreferencesKey("help_seen")
9497
val ZEN_MODE = booleanPreferencesKey("zen_mode")
9598
val TASK_ZEN_MODE = booleanPreferencesKey("task_zen_mode")
9699
val DELIVERY_MODE = stringPreferencesKey("delivery_mode")
@@ -197,6 +200,7 @@ class SettingsRepository @Inject constructor(
197200
reminderLabel = prefs[Keys.REMINDER_LABEL]
198201
?.let { runCatching { ReminderLabelStyle.valueOf(it) }.getOrNull() }
199202
?: ReminderLabelStyle.REMINDERS,
203+
helpSeen = prefs[Keys.HELP_SEEN] ?: false,
200204
)
201205
}
202206

@@ -225,6 +229,10 @@ class SettingsRepository @Inject constructor(
225229
context.dataStore.edit { it[Keys.WCAG_MODE] = enabled }
226230
}
227231

232+
suspend fun setHelpSeen(seen: Boolean) {
233+
context.dataStore.edit { it[Keys.HELP_SEEN] = seen }
234+
}
235+
228236
suspend fun setZenMode(enabled: Boolean) {
229237
context.dataStore.edit { it[Keys.ZEN_MODE] = enabled }
230238
}

0 commit comments

Comments
 (0)