From 1f5a721a3559ff329a644e8f2c3d4b4d3b9e1529 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 23:55:25 +0000 Subject: [PATCH 1/3] NFC tags: split chores by whether they are on a sticker Every chore has a tag id in Supabase whether or not a sticker exists, so the only evidence the app has is its own: TagStickerStore records, on this phone, every id it writes to a sticker and every id it reads off one (a chore tap, a tag-alarm tap, a scan to link or identify). Settings > NFC tags gains an All / On a sticker / No sticker chip row over the chores, with counts, and marks rows that are on a sticker. A sticker made elsewhere counts once it is tapped. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016rWgRBgBaGjrH7iWGmUeSv --- .../main/java/com/mapgie/dash/MainActivity.kt | 12 +++- .../dash/data/preferences/TagStickerStore.kt | 64 +++++++++++++++++++ .../dash/ui/screens/settings/TagsSubScreen.kt | 54 ++++++++++++++-- .../dash/ui/screens/settings/TagsViewModel.kt | 50 ++++++++++++++- .../ui/screens/settings/TagsUiStateTest.kt | 24 +++++++ changelog/unreleased/tags-sticker-filter.json | 6 ++ 6 files changed, 203 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/mapgie/dash/data/preferences/TagStickerStore.kt create mode 100644 changelog/unreleased/tags-sticker-filter.json diff --git a/app/src/main/java/com/mapgie/dash/MainActivity.kt b/app/src/main/java/com/mapgie/dash/MainActivity.kt index 9e7338c..5754a37 100644 --- a/app/src/main/java/com/mapgie/dash/MainActivity.kt +++ b/app/src/main/java/com/mapgie/dash/MainActivity.kt @@ -28,6 +28,7 @@ import com.mapgie.dash.data.model.ReminderDto import com.mapgie.dash.data.model.Severity import com.mapgie.dash.data.model.TagAlarmText import com.mapgie.dash.data.preferences.SettingsRepository +import com.mapgie.dash.data.preferences.TagStickerStore import com.mapgie.dash.data.preferences.ThemeMode import com.mapgie.dash.data.repository.ChoreRepository import com.mapgie.dash.nfc.NfcHandler @@ -53,6 +54,7 @@ class MainActivity : ComponentActivity() { @Inject lateinit var settingsRepository: SettingsRepository @Inject lateinit var choreRepository: ChoreRepository @Inject lateinit var tagAlarmService: TagAlarmService + @Inject lateinit var tagStickerStore: TagStickerStore private var nfcAdapter: NfcAdapter? = null private var nfcPendingIntent: PendingIntent? = null @@ -223,12 +225,16 @@ class MainActivity : ComponentActivity() { if (writeRequest != null) { val tag = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG) if (tag != null) { - nfcWriteResult = NfcHandler.writeUri(tag, writeRequest.uri) + val result = NfcHandler.writeUri(tag, writeRequest.uri) + nfcWriteResult = result + if (result == NfcWriteResult.Success) recordSticker(writeRequest.id) } return } val tagId = NfcHandler.extractTagId(intent) if (tagId != null) { + // Whatever happens next, this id was just read off a real sticker. + recordSticker(tagId) when { nfcCaptureRequested -> { nfcCaptureRequested = false @@ -240,6 +246,10 @@ class MainActivity : ComponentActivity() { intent.getStringExtra(WIDGET_DESTINATION_EXTRA)?.let { pendingWidgetDestination = it } } + private fun recordSticker(tagId: String) { + lifecycleScope.launch { runCatching { tagStickerStore.record(tagId) } } + } + // A tag-alarm's tag is resolved first, on-device and offline, so the chore path // never sees it: before this, a background tap wrote a Supabase scan row for // any id at all. Only a tag no tag-alarm owns goes on to the chore flows. diff --git a/app/src/main/java/com/mapgie/dash/data/preferences/TagStickerStore.kt b/app/src/main/java/com/mapgie/dash/data/preferences/TagStickerStore.kt new file mode 100644 index 0000000..d7f98d8 --- /dev/null +++ b/app/src/main/java/com/mapgie/dash/data/preferences/TagStickerStore.kt @@ -0,0 +1,64 @@ +package com.mapgie.dash.data.preferences + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.IOException +import java.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.tagStickerDataStore: DataStore by preferencesDataStore(name = "dash_tag_stickers") + +/** + * Which tag ids this phone has met on a physical NFC sticker, and when: an id + * it wrote to a sticker, or one it read off a sticker (a chore tap, a tag-alarm + * tap, a scan to link or identify). Every chore has a tag id in Supabase whether + * or not a sticker exists, so this is the only evidence the app has that one + * does. Kept on-device and never synced: it is this phone's experience, and + * Settings › NFC tags uses it to split chores into "on a sticker" and "no + * sticker". + */ +@Singleton +class TagStickerStore @Inject constructor( + @ApplicationContext private val context: Context, +) { + private object Keys { + val SEEN = stringPreferencesKey("seen") + } + + private val json = Json { ignoreUnknownKeys = true } + + /** Tag id to the last time it was written to or read from a sticker. */ + val seen: Flow> = context.tagStickerDataStore.data + .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } + .map { prefs -> decode(prefs[Keys.SEEN]) } + + /** Records that [tagId] was just written to, or read from, a sticker. */ + suspend fun record(tagId: String, at: Instant = Instant.now()) { + if (tagId.isBlank()) return + context.tagStickerDataStore.edit { prefs -> + val current = decode(prefs[Keys.SEEN]).mapValues { it.value.toString() } + prefs[Keys.SEEN] = json.encodeToString(current + (tagId to at.toString())) + } + } + + suspend fun current(): Map = seen.first() + + private fun decode(raw: String?): Map = + raw?.let { runCatching { json.decodeFromString>(it) }.getOrNull() } + .orEmpty() + .mapNotNull { (id, at) -> runCatching { Instant.parse(at) }.getOrNull()?.let { id to it } } + .toMap() +} diff --git a/app/src/main/java/com/mapgie/dash/ui/screens/settings/TagsSubScreen.kt b/app/src/main/java/com/mapgie/dash/ui/screens/settings/TagsSubScreen.kt index 6f8ec28..6bb804b 100644 --- a/app/src/main/java/com/mapgie/dash/ui/screens/settings/TagsSubScreen.kt +++ b/app/src/main/java/com/mapgie/dash/ui/screens/settings/TagsSubScreen.kt @@ -12,6 +12,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost @@ -32,12 +34,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.mapgie.dash.nfc.NfcWriteRequest import com.mapgie.dash.nfc.NfcWriteResult import com.mapgie.dash.ui.components.WriteTagDialog import com.mapgie.dash.ui.components.sheet.ValueChip +import com.mapgie.dash.ui.theme.PillShape /** * Settings › NFC tags: tag maintenance in one place. An Identify card reads @@ -47,6 +51,11 @@ import com.mapgie.dash.ui.components.sheet.ValueChip * through the same write dialog the chore sheet uses; a tag-alarm can also be * unlinked, or given an id here when it has none. * + * "On a sticker" is this phone's own evidence ([com.mapgie.dash.data.preferences.TagStickerStore]): + * it wrote the id to a sticker, or read it off one. Every chore has a tag id in + * Supabase whether a sticker exists or not, so the chip row over Chores splits + * them on that evidence, and a sticker made elsewhere counts once it is tapped. + * * Identify uses the activity's capture mode ([onStartNfcCapture] / * [nfcCapturedTagId]), the same one the memo sheet's scan uses, so a tap while * this page is listening is never logged as a chore or armed as an alarm. @@ -148,17 +157,54 @@ internal fun TagsSubScreen( } SettingsSectionLabel("Chores") + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + ChoreTagFilter.entries.forEach { f -> + val count = uiState.choreCount(f) + FilterChip( + selected = uiState.choreFilter == f, + onClick = { viewModel.setChoreFilter(f) }, + label = { + Text( + text = if (count > 0) "${f.label} · $count" else f.label, + fontWeight = FontWeight.ExtraBold, + ) + }, + shape = PillShape, + border = null, + // Explicit high-contrast fills (LESSONS.md #3), as on the Memos list. + colors = FilterChipDefaults.filterChipColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + labelColor = MaterialTheme.colorScheme.onSurfaceVariant, + selectedContainerColor = MaterialTheme.colorScheme.secondary, + selectedLabelColor = MaterialTheme.colorScheme.onSecondary, + ), + ) + } + } SettingsCard { + val shown = uiState.filteredChoreTags if (uiState.loading && uiState.chores.isEmpty()) { SettingsCardRow(title = "Loading chores", subtitle = "From Supabase.") } else if (uiState.choreTags.isEmpty()) { SettingsCardRow(title = "No chores", subtitle = if (uiState.error != null) "They couldn't be loaded." else "Add one from the Chores tab.") + } else if (shown.isEmpty()) { + SettingsCardRow( + title = if (uiState.choreFilter == ChoreTagFilter.ON_STICKER) "No chores on a sticker yet" else "Every chore is on a sticker", + subtitle = if (uiState.choreFilter == ChoreTagFilter.ON_STICKER) + "A chore counts once this phone writes its id to a sticker or reads it off one." else null, + ) } else { - uiState.choreTags.forEachIndexed { index, entry -> + shown.forEachIndexed { index, entry -> if (index > 0) SettingsHairline() SettingsCardRow( title = entry.name, - subtitle = entry.tagId + if (entry.archived) " · archived" else "", + subtitle = entry.tagId + + (if (entry.onSticker) " · on a sticker" else "") + + (if (entry.archived) " · archived" else ""), ) { ValueChip( text = "Write", @@ -180,7 +226,7 @@ internal fun TagsSubScreen( if (index > 0) SettingsHairline() SettingsCardRow( title = entry.name, - subtitle = entry.tagId ?: "No tag yet", + subtitle = entry.tagId?.let { it + if (entry.onSticker) " · on a sticker" else "" } ?: "No tag yet", ) { Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { ValueChip( @@ -211,7 +257,7 @@ internal fun TagsSubScreen( } } } - SettingsCaption("Write stamps the id on a blank sticker. A card that can't be written (an office pass) is linked by scanning it from the tag-alarm's own sheet instead.") + SettingsCaption("Write stamps the id on a blank sticker. A card that can't be written (an office pass) is linked by scanning it from the tag-alarm's own sheet instead. \"On a sticker\" is what this phone has written or read; a sticker made elsewhere counts once you tap it.") Spacer(Modifier.height(8.dp)) } } diff --git a/app/src/main/java/com/mapgie/dash/ui/screens/settings/TagsViewModel.kt b/app/src/main/java/com/mapgie/dash/ui/screens/settings/TagsViewModel.kt index 88e6193..91e82ac 100644 --- a/app/src/main/java/com/mapgie/dash/ui/screens/settings/TagsViewModel.kt +++ b/app/src/main/java/com/mapgie/dash/ui/screens/settings/TagsViewModel.kt @@ -6,6 +6,7 @@ import com.mapgie.dash.data.model.Chore import com.mapgie.dash.data.model.ReminderDto import com.mapgie.dash.data.model.freeTagId import com.mapgie.dash.data.model.isTagAlarm +import com.mapgie.dash.data.preferences.TagStickerStore import com.mapgie.dash.data.repository.ChoreRepository import com.mapgie.dash.data.repository.ReminderRepository import com.mapgie.dash.data.supabase.userFacingMessage @@ -15,6 +16,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.time.Instant import javax.inject.Inject /** What owns an NFC tag id: a chore row in Supabase, or an on-device tag-alarm. */ @@ -34,8 +36,17 @@ data class TagEntry( val kind: TagOwnerKind, val ownerId: String, val archived: Boolean = false, + /** This phone has written the id to a sticker, or read it off one. */ + val onSticker: Boolean = false, ) +/** The chip row over the Chores list: every chore, only those on a sticker, or only those without. */ +enum class ChoreTagFilter(val label: String) { + ALL("All"), + ON_STICKER("On a sticker"), + NO_STICKER("No sticker"), +} + /** * What Settings › NFC tags shows, as pure state so `TagsUiStateTest` can pin it: * every chore's tag (archived ones last), every tag-alarm with or without a @@ -46,16 +57,40 @@ data class TagsUiState( val error: String? = null, val chores: List = emptyList(), val reminders: List = emptyList(), + /** Tag ids this phone has met on a sticker, with when ([TagStickerStore]). */ + val stickers: Map = emptyMap(), + val choreFilter: ChoreTagFilter = ChoreTagFilter.ALL, ) { val choreTags: List get() = chores - .map { TagEntry(it.tagId, it.label, TagOwnerKind.CHORE, it.tagId, archived = it.archivedAt != null) } + .map { + TagEntry( + it.tagId, it.label, TagOwnerKind.CHORE, it.tagId, + archived = it.archivedAt != null, + onSticker = it.tagId in stickers, + ) + } .sortedWith(compareBy({ it.archived }, { it.name.lowercase() })) + /** The chores under the selected chip. */ + val filteredChoreTags: List + get() = when (choreFilter) { + ChoreTagFilter.ALL -> choreTags + ChoreTagFilter.ON_STICKER -> choreTags.filter { it.onSticker } + ChoreTagFilter.NO_STICKER -> choreTags.filterNot { it.onSticker } + } + + /** How many chores each chip would show, for its "· N". */ + fun choreCount(filter: ChoreTagFilter): Int = when (filter) { + ChoreTagFilter.ALL -> choreTags.size + ChoreTagFilter.ON_STICKER -> choreTags.count { it.onSticker } + ChoreTagFilter.NO_STICKER -> choreTags.count { !it.onSticker } + } + val tagAlarms: List get() = reminders .filter { it.isTagAlarm && it.archivedAt == null } - .map { TagEntry(it.tagId, it.subject, TagOwnerKind.TAG_ALARM, it.id) } + .map { TagEntry(it.tagId, it.subject, TagOwnerKind.TAG_ALARM, it.id, onSticker = it.tagId in stickers) } .sortedBy { it.name.lowercase() } /** Every id some chore or tag-alarm answers to. */ @@ -74,6 +109,7 @@ data class TagsUiState( class TagsViewModel @Inject constructor( private val choreRepository: ChoreRepository, private val reminderRepository: ReminderRepository, + private val tagStickerStore: TagStickerStore, ) : ViewModel() { private val _uiState = MutableStateFlow(TagsUiState()) @@ -87,9 +123,19 @@ class TagsViewModel @Inject constructor( _uiState.update { it.copy(reminders = reminders) } } } + // A tap or a write on this page changes the sticker evidence at once. + viewModelScope.launch { + tagStickerStore.seen.collect { stickers -> + _uiState.update { it.copy(stickers = stickers) } + } + } load() } + fun setChoreFilter(filter: ChoreTagFilter) { + _uiState.update { it.copy(choreFilter = filter) } + } + /** Loads the chores. A Supabase failure is reported but leaves the tag-alarms showing. */ fun load() { viewModelScope.launch { diff --git a/app/src/test/java/com/mapgie/dash/ui/screens/settings/TagsUiStateTest.kt b/app/src/test/java/com/mapgie/dash/ui/screens/settings/TagsUiStateTest.kt index 3266c7a..d6c1e89 100644 --- a/app/src/test/java/com/mapgie/dash/ui/screens/settings/TagsUiStateTest.kt +++ b/app/src/test/java/com/mapgie/dash/ui/screens/settings/TagsUiStateTest.kt @@ -6,6 +6,7 @@ import com.mapgie.dash.data.model.ReminderDto import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test +import java.time.Instant /** * What Settings › NFC tags lists and resolves: every chore's tag with archived @@ -59,6 +60,29 @@ class TagsUiStateTest { assertNull(state.identify("old-office")) } + // ── On a sticker ────────────────────────────────────────────────────────── + + private val withStickers = state.copy( + stickers = mapOf("laundry" to Instant.parse("2026-07-10T08:00:00Z"), "office-a" to Instant.parse("2026-07-10T08:00:00Z")), + ) + + @Test + fun `a chore is on a sticker only when this phone has written or read its id`() { + assertEquals(listOf(false, true, false), withStickers.choreTags.map { it.onSticker }) + assertEquals(listOf(false, true), withStickers.tagAlarms.map { it.onSticker }) + } + + @Test + fun `the sticker chips split the chores and count each side`() { + assertEquals(listOf("Laundry"), withStickers.copy(choreFilter = ChoreTagFilter.ON_STICKER).filteredChoreTags.map { it.name }) + assertEquals(listOf("Car service", "Bins"), withStickers.copy(choreFilter = ChoreTagFilter.NO_STICKER).filteredChoreTags.map { it.name }) + assertEquals(3, withStickers.choreCount(ChoreTagFilter.ALL)) + assertEquals(1, withStickers.choreCount(ChoreTagFilter.ON_STICKER)) + assertEquals(2, withStickers.choreCount(ChoreTagFilter.NO_STICKER)) + // Without any evidence, nothing is on a sticker and the All chip shows everything. + assertEquals(3, state.copy(choreFilter = ChoreTagFilter.NO_STICKER).filteredChoreTags.size) + } + @Test fun `a free tag id comes from the name and steps around ids in use`() { assertEquals("home", state.freeTagIdFor("Home")) diff --git a/changelog/unreleased/tags-sticker-filter.json b/changelog/unreleased/tags-sticker-filter.json new file mode 100644 index 0000000..5318531 --- /dev/null +++ b/changelog/unreleased/tags-sticker-filter.json @@ -0,0 +1,6 @@ +{ + "bump": "minor", + "added": [ + "Settings > NFC tags can show only the chores that are on a sticker, or only those without one. A chore counts as on a sticker once this phone has written its id to a tag or read it off one; stickers made elsewhere count after a single tap." + ] +} From 90dfe00b5c93084f298bca212269783873457fea Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 00:02:45 +0000 Subject: [PATCH 2/3] NFC tags: erase a sticker so it can be written for something else Settings > NFC tags gains Erase beside Scan on the Identify card. Behind a confirm, the next tag held to the phone has its NDEF message replaced with an empty record, so a read finds nothing and the sticker is ready for a new write; a never-formatted tag counts as already blank. The id it carried is read first and dropped from this phone's sticker record, and the write dialog says "Erase tag" / "Tag erased" for the run. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016rWgRBgBaGjrH7iWGmUeSv --- .../main/java/com/mapgie/dash/MainActivity.kt | 19 ++++++-- .../dash/data/preferences/TagStickerStore.kt | 8 ++++ .../java/com/mapgie/dash/nfc/NfcHandler.kt | 32 ++++++++++++-- .../dash/ui/components/WriteTagDialog.kt | 8 ++-- .../mapgie/dash/ui/navigation/DashNavGraph.kt | 1 + .../ui/screens/settings/SettingsScreen.kt | 2 + .../dash/ui/screens/settings/TagsSubScreen.kt | 44 ++++++++++++++++--- changelog/unreleased/erase-tag.json | 6 +++ 8 files changed, 104 insertions(+), 16 deletions(-) create mode 100644 changelog/unreleased/erase-tag.json diff --git a/app/src/main/java/com/mapgie/dash/MainActivity.kt b/app/src/main/java/com/mapgie/dash/MainActivity.kt index 5754a37..eda677a 100644 --- a/app/src/main/java/com/mapgie/dash/MainActivity.kt +++ b/app/src/main/java/com/mapgie/dash/MainActivity.kt @@ -225,9 +225,18 @@ class MainActivity : ComponentActivity() { if (writeRequest != null) { val tag = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG) if (tag != null) { - val result = NfcHandler.writeUri(tag, writeRequest.uri) - nfcWriteResult = result - if (result == NfcWriteResult.Success) recordSticker(writeRequest.id) + val uri = writeRequest.uri + if (uri == null) { + // An erase: note which id is leaving the sticker, then wipe it. + val leaving = NfcHandler.currentTagId(tag) + val result = NfcHandler.eraseTag(tag) + nfcWriteResult = result + if (result == NfcWriteResult.Success && leaving != null) forgetSticker(leaving) + } else { + val result = NfcHandler.writeUri(tag, uri) + nfcWriteResult = result + if (result == NfcWriteResult.Success) recordSticker(writeRequest.id) + } } return } @@ -250,6 +259,10 @@ class MainActivity : ComponentActivity() { lifecycleScope.launch { runCatching { tagStickerStore.record(tagId) } } } + private fun forgetSticker(tagId: String) { + lifecycleScope.launch { runCatching { tagStickerStore.forget(tagId) } } + } + // A tag-alarm's tag is resolved first, on-device and offline, so the chore path // never sees it: before this, a background tap wrote a Supabase scan row for // any id at all. Only a tag no tag-alarm owns goes on to the chore flows. diff --git a/app/src/main/java/com/mapgie/dash/data/preferences/TagStickerStore.kt b/app/src/main/java/com/mapgie/dash/data/preferences/TagStickerStore.kt index d7f98d8..c4b295f 100644 --- a/app/src/main/java/com/mapgie/dash/data/preferences/TagStickerStore.kt +++ b/app/src/main/java/com/mapgie/dash/data/preferences/TagStickerStore.kt @@ -54,6 +54,14 @@ class TagStickerStore @Inject constructor( } } + /** Drops [tagId] after the sticker carrying it was erased. */ + suspend fun forget(tagId: String) { + context.tagStickerDataStore.edit { prefs -> + val current = decode(prefs[Keys.SEEN]).mapValues { it.value.toString() } + prefs[Keys.SEEN] = json.encodeToString(current - tagId) + } + } + suspend fun current(): Map = seen.first() private fun decode(raw: String?): Map = diff --git a/app/src/main/java/com/mapgie/dash/nfc/NfcHandler.kt b/app/src/main/java/com/mapgie/dash/nfc/NfcHandler.kt index 82b2d1d..7fea3de 100644 --- a/app/src/main/java/com/mapgie/dash/nfc/NfcHandler.kt +++ b/app/src/main/java/com/mapgie/dash/nfc/NfcHandler.kt @@ -24,12 +24,15 @@ sealed class NfcWriteResult { * marks a write started on Settings › NFC tags, which shows its own dialog. */ data class NfcWriteRequest(val kind: Kind, val id: String, val fromSettings: Boolean = false) { - enum class Kind { CHORE, MEMO } + /** What to put on the tag; [ERASE] wipes it instead, [id] unused. */ + enum class Kind { CHORE, MEMO, ERASE } - val uri: String + /** The URI to write; null for an erase. */ + val uri: String? get() = when (kind) { Kind.CHORE -> NfcHandler.choreTagUri(id) Kind.MEMO -> NfcHandler.memoTagUri(id) + Kind.ERASE -> null } } @@ -79,8 +82,29 @@ object NfcHandler { fun writeTagId(tag: Tag, tagId: String): NfcWriteResult = writeUri(tag, choreTagUri(tagId)) /** Writes [uri] onto [tag] as a single NDEF URI record, formatting blank tags if needed. */ - fun writeUri(tag: Tag, uri: String): NfcWriteResult { - val message = NdefMessage(arrayOf(NdefRecord.createUri(uri))) + fun writeUri(tag: Tag, uri: String): NfcWriteResult = + writeMessage(tag, NdefMessage(arrayOf(NdefRecord.createUri(uri)))) + + /** + * The id [tag] currently carries, read from its cached NDEF message without + * connecting, or null when it has none the app can read. Used before an + * erase to say which id is leaving the sticker. + */ + fun currentTagId(tag: Tag): String? = + Ndef.get(tag)?.cachedNdefMessage?.records?.firstNotNullOfOrNull { extractFromRecord(it) } + + /** + * Wipes [tag]: its NDEF message becomes a single empty record, so the next + * read finds nothing and the sticker is ready to be written for something + * else. A tag that was never formatted has nothing on it and counts as done. + */ + fun eraseTag(tag: Tag): NfcWriteResult { + if (Ndef.get(tag) == null) return NfcWriteResult.Success + val empty = NdefRecord(NdefRecord.TNF_EMPTY, ByteArray(0), ByteArray(0), ByteArray(0)) + return writeMessage(tag, NdefMessage(arrayOf(empty))) + } + + private fun writeMessage(tag: Tag, message: NdefMessage): NfcWriteResult { return try { val ndef = Ndef.get(tag) if (ndef != null) { diff --git a/app/src/main/java/com/mapgie/dash/ui/components/WriteTagDialog.kt b/app/src/main/java/com/mapgie/dash/ui/components/WriteTagDialog.kt index 7123eb5..1310269 100644 --- a/app/src/main/java/com/mapgie/dash/ui/components/WriteTagDialog.kt +++ b/app/src/main/java/com/mapgie/dash/ui/components/WriteTagDialog.kt @@ -25,11 +25,13 @@ import com.mapgie.dash.nfc.NfcWriteResult @Composable fun WriteTagDialog( result: NfcWriteResult?, - onDismiss: () -> Unit + onDismiss: () -> Unit, + /** True while erasing rather than writing: the title and success line say so. */ + erasing: Boolean = false, ) { AlertDialog( onDismissRequest = onDismiss, - title = { Text("Write tag") }, + title = { Text(if (erasing) "Erase tag" else "Write tag") }, text = { when (result) { null -> Row { @@ -41,7 +43,7 @@ fun WriteTagDialog( ) } NfcWriteResult.Success -> Text( - "Tag written successfully.", + if (erasing) "Tag erased. It's blank and ready to be written again." else "Tag written successfully.", modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite } ) NfcWriteResult.NotWritable -> Text( 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 f8633b0..b4c5899 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 @@ -298,6 +298,7 @@ fun DashNavGraph( onCancelNfcCapture = onCancelNfcCapture, onNfcCaptureConsumed = onNfcCaptureConsumed, tagWritePending = nfcWriteRequest?.fromSettings == true, + tagErasePending = nfcWriteRequest?.kind == NfcWriteRequest.Kind.ERASE, nfcWriteResult = nfcWriteResult, onStartTagWrite = { request -> onStartNfcWriteRequest(request.copy(fromSettings = true)) }, onCancelNfcWrite = onCancelNfcWrite, 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 df53c8a..cf1d6b7 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 @@ -125,6 +125,7 @@ fun SettingsScreen( onCancelNfcCapture: () -> Unit = {}, onNfcCaptureConsumed: () -> Unit = {}, tagWritePending: Boolean = false, + tagErasePending: Boolean = false, nfcWriteResult: NfcWriteResult? = null, onStartTagWrite: (NfcWriteRequest) -> Unit = {}, onCancelNfcWrite: () -> Unit = {}, @@ -187,6 +188,7 @@ fun SettingsScreen( onCancelNfcCapture = onCancelNfcCapture, onNfcCaptureConsumed = onNfcCaptureConsumed, tagWritePending = tagWritePending, + tagErasePending = tagErasePending, nfcWriteResult = nfcWriteResult, onStartTagWrite = onStartTagWrite, onCancelNfcWrite = onCancelNfcWrite, diff --git a/app/src/main/java/com/mapgie/dash/ui/screens/settings/TagsSubScreen.kt b/app/src/main/java/com/mapgie/dash/ui/screens/settings/TagsSubScreen.kt index 6bb804b..fbf217a 100644 --- a/app/src/main/java/com/mapgie/dash/ui/screens/settings/TagsSubScreen.kt +++ b/app/src/main/java/com/mapgie/dash/ui/screens/settings/TagsSubScreen.kt @@ -68,6 +68,8 @@ internal fun TagsSubScreen( onCancelNfcCapture: () -> Unit, onNfcCaptureConsumed: () -> Unit, tagWritePending: Boolean, + /** The pending write from Settings is an erase; the dialog says so. */ + tagErasePending: Boolean = false, nfcWriteResult: NfcWriteResult?, onStartTagWrite: (NfcWriteRequest) -> Unit, onCancelNfcWrite: () -> Unit, @@ -81,6 +83,7 @@ internal fun TagsSubScreen( // The last tag read on this page: its id, and what it resolved to (null for unknown). var readId by rememberSaveable { mutableStateOf(null) } var pendingUnlink by remember { mutableStateOf(null) } + var confirmErase by remember { mutableStateOf(false) } LaunchedEffect(uiState.error) { uiState.error?.let { @@ -147,14 +150,25 @@ internal fun TagsSubScreen( .weight(1f) .semantics { liveRegion = LiveRegionMode.Polite }, ) - ValueChip( - text = if (scanning) "Cancel" else "Scan", - onClick = { scanning = !scanning }, - contentDescription = if (scanning) "Stop listening for a tag" else "Scan a tag to identify it", - chevron = false, - ) + Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { + ValueChip( + text = if (scanning) "Cancel" else "Scan", + onClick = { scanning = !scanning }, + contentDescription = if (scanning) "Stop listening for a tag" else "Scan a tag to identify it", + chevron = false, + ) + if (!scanning) { + ValueChip( + text = "Erase", + onClick = { confirmErase = true }, + contentDescription = "Erase the next tag held to the phone", + chevron = false, + ) + } + } } } + SettingsCaption("Erase wipes whatever a sticker carries so it can be written for something else. The chore or tag-alarm it belonged to is untouched.") SettingsSectionLabel("Chores") Row( @@ -265,12 +279,30 @@ internal fun TagsSubScreen( if (tagWritePending) { WriteTagDialog( result = nfcWriteResult, + erasing = tagErasePending, onDismiss = { if (nfcWriteResult != null) onNfcWriteResultConsumed() else onCancelNfcWrite() } ) } + if (confirmErase) { + AlertDialog( + onDismissRequest = { confirmErase = false }, + title = { Text("Erase a tag?") }, + text = { Text("The next tag you hold to the phone is wiped. Whatever chore or tag-alarm it pointed at stays in the app and can be written to a tag again.") }, + confirmButton = { + TextButton(onClick = { + confirmErase = false + onStartTagWrite(NfcWriteRequest(NfcWriteRequest.Kind.ERASE, "")) + }) { Text("Erase", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { + TextButton(onClick = { confirmErase = false }) { Text("Cancel") } + }, + ) + } + pendingUnlink?.let { entry -> AlertDialog( onDismissRequest = { pendingUnlink = null }, diff --git a/changelog/unreleased/erase-tag.json b/changelog/unreleased/erase-tag.json new file mode 100644 index 0000000..a1c86c3 --- /dev/null +++ b/changelog/unreleased/erase-tag.json @@ -0,0 +1,6 @@ +{ + "bump": "minor", + "added": [ + "Settings > NFC tags can erase a sticker: tap Erase, hold the tag, and it is wiped and ready to be written for something else. The chore or tag-alarm it pointed at is untouched." + ] +} From 2771383a74351c8f404be99bd7276ae0f0a54484 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 00:06:52 +0000 Subject: [PATCH 3/3] Tag-alarms: a renamed tag is written to its sticker on Save Renaming changed what the memo answered to while the sticker kept the old name, so a tap matched nothing. Save on a renamed tag now reads "Save and write tag" and goes straight into the write dialog, with a note naming the old and new ids; the rename dialog warns up front. The write dialog gains an optional note line for that. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016rWgRBgBaGjrH7iWGmUeSv --- .../dash/ui/components/AddReminderSheet.kt | 29 ++++++++++++++----- .../dash/ui/components/WriteTagDialog.kt | 4 ++- .../screens/reminders/RemindersListScreen.kt | 8 +++-- changelog/unreleased/rename-rewrites-tag.json | 6 ++++ 4 files changed, 37 insertions(+), 10 deletions(-) create mode 100644 changelog/unreleased/rename-rewrites-tag.json diff --git a/app/src/main/java/com/mapgie/dash/ui/components/AddReminderSheet.kt b/app/src/main/java/com/mapgie/dash/ui/components/AddReminderSheet.kt index 0e52488..a4d3315 100644 --- a/app/src/main/java/com/mapgie/dash/ui/components/AddReminderSheet.kt +++ b/app/src/main/java/com/mapgie/dash/ui/components/AddReminderSheet.kt @@ -185,7 +185,7 @@ fun AddReminderSheet( * from the title ("Office A" becomes "office-a") when none was named, so a * new memo can be written straight away. */ - onWriteTag: ((tagId: String) -> Unit)? = null, + onWriteTag: ((tagId: String, note: String?) -> Unit)? = null, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetScope = rememberCoroutineScope() @@ -721,7 +721,7 @@ fun AddReminderSheet( onDraftClear() onSave(buildInsert(tagOverride = writeId)) sheetScope.launch { sheetState.hide() }.invokeOnCompletion { - onWriteTag(writeId) + onWriteTag(writeId, null) onDismiss() } }, @@ -813,14 +813,25 @@ fun AddReminderSheet( ExistingMeta(existing = existing) } + // A renamed tag is only half done until the sticker says the new name too: + // Save goes straight on to the write dialog for it, with a note saying why. + val renamedTag = tagAlarmOn && onWriteTag != null && + opened.tagId.isNotBlank() && tagIdValue.isNotBlank() && tagIdValue != opened.tagId SheetPrimaryRow( - actionLabel = "Save", + actionLabel = if (renamedTag) "Save and write tag" else "Save", actionEnabled = canSave, onCancel = { requestDismiss() }, onAction = { onDraftClear() onSave(buildInsert()) - sheetScope.launch { sheetState.hide() }.invokeOnCompletion { onDismiss() } + val newId = tagIdValue + val oldId = opened.tagId + sheetScope.launch { sheetState.hide() }.invokeOnCompletion { + if (renamedTag && onWriteTag != null) { + onWriteTag(newId, "The sticker still says \"$oldId\". Hold it to the phone to change it to \"$newId\".") + } + onDismiss() + } }, ) @@ -1019,9 +1030,13 @@ private fun TagNameDialog( modifier = Modifier.fillMaxWidth(), ) Text( - text = if (owner != null) "\"$folded\" already belongs to $owner." - else if (typed.isBlank()) "Something short: where you are heading, or the alarm's name." - else "Written to the tag as \"$folded\".", + text = when { + owner != null -> "\"$folded\" already belongs to $owner." + typed.isBlank() -> "Something short: where you are heading, or the alarm's name." + current.isNotBlank() && folded != current -> + "Written to the tag as \"$folded\". A sticker already written keeps the old name until you write it again; Save will ask you to." + else -> "Written to the tag as \"$folded\"." + }, style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.SemiBold), color = if (owner != null) MaterialTheme.colorScheme.error else tokens.inkFaint, modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, diff --git a/app/src/main/java/com/mapgie/dash/ui/components/WriteTagDialog.kt b/app/src/main/java/com/mapgie/dash/ui/components/WriteTagDialog.kt index 1310269..63c8f11 100644 --- a/app/src/main/java/com/mapgie/dash/ui/components/WriteTagDialog.kt +++ b/app/src/main/java/com/mapgie/dash/ui/components/WriteTagDialog.kt @@ -28,6 +28,8 @@ fun WriteTagDialog( onDismiss: () -> Unit, /** True while erasing rather than writing: the title and success line say so. */ erasing: Boolean = false, + /** Why this write is happening, shown above the hold-the-tag line while waiting. */ + note: String? = null, ) { AlertDialog( onDismissRequest = onDismiss, @@ -38,7 +40,7 @@ fun WriteTagDialog( CircularProgressIndicator(modifier = Modifier.size(20.dp)) Spacer(modifier = Modifier.width(12.dp)) Text( - "Hold the NFC tag near the back of your phone.", + (note?.let { "$it\n\n" } ?: "") + "Hold the NFC tag near the back of your phone.", modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite } ) } diff --git a/app/src/main/java/com/mapgie/dash/ui/screens/reminders/RemindersListScreen.kt b/app/src/main/java/com/mapgie/dash/ui/screens/reminders/RemindersListScreen.kt index b02d455..f7fb23c 100644 --- a/app/src/main/java/com/mapgie/dash/ui/screens/reminders/RemindersListScreen.kt +++ b/app/src/main/java/com/mapgie/dash/ui/screens/reminders/RemindersListScreen.kt @@ -151,6 +151,8 @@ fun RemindersListScreen( // The id, not the record, so the open sheet survives process death and // always shows the freshest copy after a reload. var editTargetId by rememberSaveable { mutableStateOf(null) } + // Why the pending tag write is happening (a renamed tag), for the write dialog. + var writeNote by rememberSaveable { mutableStateOf(null) } LaunchedEffect(pendingAddIntent) { if (pendingAddIntent == AddMenuOption.REMINDER) { @@ -355,7 +357,7 @@ fun RemindersListScreen( onStartScan = onStartNfcCapture, onCancelScan = onCancelNfcCapture, onScanConsumed = onNfcCaptureConsumed, - onWriteTag = { tagId -> onStartMemoTagWrite(tagId) }, + onWriteTag = { tagId, note -> writeNote = note; onStartMemoTagWrite(tagId) }, ) } @@ -379,14 +381,16 @@ fun RemindersListScreen( onScanConsumed = onNfcCaptureConsumed, onArmTagAlarm = { viewModel.armTagAlarm(reminder.id) }, onDisarmTagAlarm = { viewModel.disarmTagAlarm(reminder.id) }, - onWriteTag = { tagId -> onStartMemoTagWrite(tagId) }, + onWriteTag = { tagId, note -> writeNote = note; onStartMemoTagWrite(tagId) }, ) } if (memoTagWritePending) { WriteTagDialog( result = nfcWriteResult, + note = writeNote, onDismiss = { + writeNote = null if (nfcWriteResult != null) onNfcWriteResultConsumed() else onCancelNfcWrite() } ) diff --git a/changelog/unreleased/rename-rewrites-tag.json b/changelog/unreleased/rename-rewrites-tag.json new file mode 100644 index 0000000..9e4535a --- /dev/null +++ b/changelog/unreleased/rename-rewrites-tag.json @@ -0,0 +1,6 @@ +{ + "bump": "patch", + "fixed": [ + "Renaming a tag-alarm's tag no longer leaves its sticker answering to the old name: Save goes straight on to writing the sticker, and the rename dialog says so up front." + ] +}