diff --git a/app/src/main/java/com/mapgie/dash/MainActivity.kt b/app/src/main/java/com/mapgie/dash/MainActivity.kt index 060dfb8..9e7338c 100644 --- a/app/src/main/java/com/mapgie/dash/MainActivity.kt +++ b/app/src/main/java/com/mapgie/dash/MainActivity.kt @@ -166,12 +166,8 @@ class MainActivity : ComponentActivity() { }, nfcWriteRequest = nfcWriteRequest, nfcWriteResult = nfcWriteResult, - onStartNfcWrite = { tagId -> - nfcWriteRequest = NfcWriteRequest(NfcWriteRequest.Kind.CHORE, tagId) - nfcWriteResult = null - }, - onStartMemoTagWrite = { memoId -> - nfcWriteRequest = NfcWriteRequest(NfcWriteRequest.Kind.MEMO, memoId) + onStartNfcWriteRequest = { request -> + nfcWriteRequest = request nfcWriteResult = null }, onCancelNfcWrite = { diff --git a/app/src/main/java/com/mapgie/dash/data/model/TagAlarm.kt b/app/src/main/java/com/mapgie/dash/data/model/TagAlarm.kt index a2321fb..5b9e91a 100644 --- a/app/src/main/java/com/mapgie/dash/data/model/TagAlarm.kt +++ b/app/src/main/java/com/mapgie/dash/data/model/TagAlarm.kt @@ -140,6 +140,30 @@ fun ReminderDto.conflictingTagAlarms(all: List, zone: ZoneId = Zone } } +/** + * A friendly tag id for a tag-alarm, from its name: "Office A" becomes + * "office-a". Lower-case ASCII letters, digits and single hyphens only, so it + * reads well on a card and travels safely inside the `chordash://memo?memo=` + * URI; at most 40 characters; "memo" when nothing usable is left. + */ +fun suggestTagId(subject: String): String { + val slug = subject.lowercase() + .replace(Regex("[^a-z0-9]+"), "-") + .trim('-') + .take(40) + .trimEnd('-') + return slug.ifEmpty { "memo" } +} + +/** [suggestTagId], made unique against [taken] by a numeric suffix: "office-a-2". */ +fun freeTagId(subject: String, taken: Set): String { + val base = suggestTagId(subject) + if (base !in taken) return base + var n = 2 + while ("$base-$n" in taken) n++ + return "$base-$n" +} + /** The words the tap feedback and the conflict question use, kept testable. */ object TagAlarmText { diff --git a/app/src/main/java/com/mapgie/dash/data/repository/ReminderRepository.kt b/app/src/main/java/com/mapgie/dash/data/repository/ReminderRepository.kt index 694a073..e6e8b35 100644 --- a/app/src/main/java/com/mapgie/dash/data/repository/ReminderRepository.kt +++ b/app/src/main/java/com/mapgie/dash/data/repository/ReminderRepository.kt @@ -95,6 +95,10 @@ class ReminderRepository @Inject constructor( suspend fun disarmTagAlarm(id: String): ReminderDto? = update(id) { if (it.isTagAlarm) it.disarmed() else it } + /** Links (or, with null, unlinks) the NFC tag a tag-alarm answers to. Nothing else changes. */ + suspend fun setTagAlarmTag(id: String, tagId: String?): ReminderDto? = + update(id) { if (it.isTagAlarm) it.copy(tagId = tagId?.trim()?.ifBlank { null }) else it } + /** * Done from the notification or ring screen. A once-only memo completes; a * repeating one is unchanged, its next ring stays armed (see [afterDone]). 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 0f03d82..82b2d1d 100644 --- a/app/src/main/java/com/mapgie/dash/nfc/NfcHandler.kt +++ b/app/src/main/java/com/mapgie/dash/nfc/NfcHandler.kt @@ -18,11 +18,12 @@ sealed class NfcWriteResult { /** * A tag the app is waiting to write: a chore's tag id (`chordash://tag?tag=`) - * or a memo's own id (`chordash://memo?memo=`). Both read back through + * or a tag-alarm's tag id (`chordash://memo?memo=`). Both read back through * [NfcHandler.extractTagId] as the bare id, so one id space serves chores and - * tag-alarms alike; the host only says which kind minted it. + * tag-alarms alike; the host only says which kind minted it. [fromSettings] + * marks a write started on Settings › NFC tags, which shows its own dialog. */ -data class NfcWriteRequest(val kind: Kind, val id: String) { +data class NfcWriteRequest(val kind: Kind, val id: String, val fromSettings: Boolean = false) { enum class Kind { CHORE, MEMO } val uri: String 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 0ea524a..0e52488 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 @@ -35,6 +35,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheetProperties +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberDatePickerState @@ -82,6 +83,7 @@ import com.mapgie.dash.data.model.nextOccurrence import com.mapgie.dash.data.model.parseRepeatDays import com.mapgie.dash.data.model.parseRingTimes import com.mapgie.dash.data.model.remindAtInstant +import com.mapgie.dash.data.model.suggestTagId import com.mapgie.dash.ui.components.core.LocalReminderLabel import com.mapgie.dash.ui.components.core.MetaCaption import com.mapgie.dash.ui.components.sheet.DraftResumeRow @@ -177,11 +179,13 @@ fun AddReminderSheet( onArmTagAlarm: (() -> Unit)? = null, onDisarmTagAlarm: (() -> Unit)? = null, /** - * Write this memo's own id to a tag: the sheet saves the memo with its id as - * the linked tag, closes, and the caller waits for the tap. Only offered for - * a saved tag-alarm, since a new one has no id until it is saved. + * Write the tag-alarm's tag id to a blank tag: the sheet saves the memo with + * that id as its linked tag, closes, and hands the id to the caller, who + * waits for the tap. The id is the one named in the Tag row, or one made + * from the title ("Office A" becomes "office-a") when none was named, so a + * new memo can be written straight away. */ - onWriteTag: (() -> Unit)? = null, + onWriteTag: ((tagId: String) -> Unit)? = null, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetScope = rememberCoroutineScope() @@ -219,6 +223,7 @@ fun AddReminderSheet( var scanning by rememberSaveable { mutableStateOf(false) } var tagError by rememberSaveable { mutableStateOf(null) } var tagMenuOpen by rememberSaveable { mutableStateOf(false) } + var showTagNameDialog by rememberSaveable { mutableStateOf(false) } var showFollowUpPicker by rememberSaveable { mutableStateOf(false) } var showDatePicker by rememberSaveable { mutableStateOf(false) } @@ -691,26 +696,39 @@ fun AddReminderSheet( else "Tag: ${tagIdValue.ifBlank { "none" }}. Change tag", ) DropdownMenu(expanded = tagMenuOpen, onDismissRequest = { tagMenuOpen = false }) { - // Writing stamps the memo's own id on the tag, so the record is - // saved with that id first; the tap itself happens after the sheet - // closes, in the write dialog the list screen shows. - if (existing != null && onWriteTag != null) { + DropdownMenuItem( + text = { Text(if (tagIdValue.isBlank()) "Name the tag" else "Rename the tag") }, + onClick = { tagMenuOpen = false; showTagNameDialog = true }, + ) + // Writing stamps the tag id on the tag, so the record is saved with + // that id first; the tap itself happens after the sheet closes, in + // the write dialog the list screen shows. A memo with no title has + // nothing to save yet, so the item waits for one. + if (onWriteTag != null) { + val writeId = tagIdValue.ifBlank { suggestTagId(subject) } + val writeOwner = takenTagIds[writeId] DropdownMenuItem( - text = { Text("Write this ${kindWord.lowercase()} to a tag") }, + text = { Text("Write \"$writeId\" to a blank tag") }, + enabled = subject.isNotBlank(), onClick = { tagMenuOpen = false - tagIdValue = existing.id + if (writeOwner != null) { + tagError = "\"$writeId\" already belongs to $writeOwner. Name the tag something else first." + return@DropdownMenuItem + } + tagIdValue = writeId + tagError = null onDraftClear() - onSave(buildInsert(tagOverride = existing.id)) + onSave(buildInsert(tagOverride = writeId)) sheetScope.launch { sheetState.hide() }.invokeOnCompletion { - onWriteTag() + onWriteTag(writeId) onDismiss() } }, ) } DropdownMenuItem( - text = { Text(if (tagIdValue.isBlank()) "Scan a tag" else "Scan a different tag") }, + text = { Text(if (tagIdValue.isBlank()) "Scan a card that has an id" else "Scan a different card") }, onClick = { tagMenuOpen = false; startScan() }, ) if (tagIdValue.isNotBlank()) { @@ -724,8 +742,7 @@ fun AddReminderSheet( } if (!scanning && tagIdValue.isBlank() && tagError == null) { Text( - text = if (existing == null) "Save first, then write this ${kindWord.lowercase()} to a blank tag from the Tag row. Or scan a card that already has an id." - else "Write this ${kindWord.lowercase()} to a blank tag, or scan a card that already has an id.", + text = "Name the tag, then write it to a blank sticker. Or scan a card that already carries an id.", style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.SemiBold), color = tokens.inkFaint, modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 10.dp), @@ -890,6 +907,20 @@ fun AddReminderSheet( ) } + if (showTagNameDialog) { + TagNameDialog( + current = tagIdValue, + suggested = suggestTagId(subject), + takenTagIds = takenTagIds, + onConfirm = { named -> + tagIdValue = named + tagError = null + showTagNameDialog = false + }, + onDismiss = { showTagNameDialog = false }, + ) + } + if (showFollowUpPicker) { // Opens a quarter of an hour after the last ring of the morning so far. val suggested = ringTimes.last().plusMinutes(15) @@ -957,6 +988,58 @@ fun AddReminderSheet( private fun Instant.withSecondsZeroed(): Instant = truncatedTo(ChronoUnit.MINUTES) +/** + * Names a tag-alarm's tag. Whatever is typed is folded to a friendly id + * ("Waterloo office" becomes "waterloo-office") and shown as it will be + * written; an id a chore or another tag-alarm owns is refused with the owner named. + */ +@Composable +private fun TagNameDialog( + current: String, + suggested: String, + takenTagIds: Map, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var typed by rememberSaveable { mutableStateOf(current.ifBlank { suggested }) } + val folded = suggestTagId(typed) + val owner = takenTagIds[folded] + val tokens = LocalDashTokens.current + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Name the tag") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = typed, + onValueChange = { typed = it }, + singleLine = true, + label = { Text("Tag name") }, + isError = owner != null, + 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\".", + style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.SemiBold), + color = if (owner != null) MaterialTheme.colorScheme.error else tokens.inkFaint, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) + } + }, + confirmButton = { + TextButton( + enabled = typed.isNotBlank() && owner == null, + onClick = { onConfirm(folded) }, + ) { Text("Done") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} + /** The large serif time on the Time row ("7:00" with a smaller "AM"); tapping opens the picker. */ @Composable private fun TimeValue(text: String, onClick: () -> Unit) { 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 679b1cb..f8633b0 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 @@ -100,8 +100,7 @@ fun DashNavGraph( onReminderViewConsumed: () -> Unit = {}, nfcWriteRequest: NfcWriteRequest?, nfcWriteResult: NfcWriteResult?, - onStartNfcWrite: (String) -> Unit, - onStartMemoTagWrite: (String) -> Unit = {}, + onStartNfcWriteRequest: (NfcWriteRequest) -> Unit, onCancelNfcWrite: () -> Unit, onNfcWriteResultConsumed: () -> Unit, nfcCapturedTagId: String? = null, @@ -246,11 +245,14 @@ fun DashNavGraph( ChoreListScreen( pendingNfcTagId = pendingNfcTagId, onNfcConsumed = onNfcConsumed, - // Each tab shows the write dialog for its own kind only, so a memo - // write started on Memos never pops a dialog here. - nfcWriteRequest = nfcWriteRequest?.takeIf { it.kind == NfcWriteRequest.Kind.CHORE }?.id, + // Each tab shows the write dialog for its own writes only, so a memo + // write started on Memos or Settings never pops a dialog here. + nfcWriteRequest = nfcWriteRequest + ?.takeIf { it.kind == NfcWriteRequest.Kind.CHORE && !it.fromSettings }?.id, nfcWriteResult = nfcWriteResult, - onStartNfcWrite = onStartNfcWrite, + onStartNfcWrite = { tagId -> + onStartNfcWriteRequest(NfcWriteRequest(NfcWriteRequest.Kind.CHORE, tagId)) + }, onCancelNfcWrite = onCancelNfcWrite, onNfcWriteResultConsumed = onNfcWriteResultConsumed, pendingAddIntent = pendingAddIntent, @@ -271,9 +273,13 @@ fun DashNavGraph( onStartNfcCapture = onStartNfcCapture, onCancelNfcCapture = onCancelNfcCapture, onNfcCaptureConsumed = onNfcCaptureConsumed, - memoTagWritePending = nfcWriteRequest?.kind == NfcWriteRequest.Kind.MEMO, + memoTagWritePending = nfcWriteRequest?.let { + it.kind == NfcWriteRequest.Kind.MEMO && !it.fromSettings + } ?: false, nfcWriteResult = nfcWriteResult, - onStartMemoTagWrite = onStartMemoTagWrite, + onStartMemoTagWrite = { tagId -> + onStartNfcWriteRequest(NfcWriteRequest(NfcWriteRequest.Kind.MEMO, tagId)) + }, onCancelNfcWrite = onCancelNfcWrite, onNfcWriteResultConsumed = onNfcWriteResultConsumed, onOpenReminderSettings = { @@ -287,6 +293,15 @@ fun DashNavGraph( onNavigateToLicenses = { navController.navigate("licenses") }, pendingSubScreen = pendingSettingsSubScreen, onPendingSubScreenConsumed = { pendingSettingsSubScreen = null }, + nfcCapturedTagId = nfcCapturedTagId, + onStartNfcCapture = onStartNfcCapture, + onCancelNfcCapture = onCancelNfcCapture, + onNfcCaptureConsumed = onNfcCaptureConsumed, + tagWritePending = nfcWriteRequest?.fromSettings == true, + nfcWriteResult = nfcWriteResult, + onStartTagWrite = { request -> onStartNfcWriteRequest(request.copy(fromSettings = true)) }, + onCancelNfcWrite = onCancelNfcWrite, + onNfcWriteResultConsumed = onNfcWriteResultConsumed, ) } composable("licenses") { 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 f29dd58..b02d455 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 @@ -105,7 +105,7 @@ fun RemindersListScreen( /** A tag-alarm's "Write tag" is waiting for a tag (or has its result); shows the write dialog. */ memoTagWritePending: Boolean = false, nfcWriteResult: NfcWriteResult? = null, - onStartMemoTagWrite: (memoId: String) -> Unit = {}, + onStartMemoTagWrite: (tagId: String) -> Unit = {}, onCancelNfcWrite: () -> Unit = {}, onNfcWriteResultConsumed: () -> Unit = {}, onOpenReminderSettings: () -> Unit, @@ -355,6 +355,7 @@ fun RemindersListScreen( onStartScan = onStartNfcCapture, onCancelScan = onCancelNfcCapture, onScanConsumed = onNfcCaptureConsumed, + onWriteTag = { tagId -> onStartMemoTagWrite(tagId) }, ) } @@ -378,7 +379,7 @@ fun RemindersListScreen( onScanConsumed = onNfcCaptureConsumed, onArmTagAlarm = { viewModel.armTagAlarm(reminder.id) }, onDisarmTagAlarm = { viewModel.disarmTagAlarm(reminder.id) }, - onWriteTag = { onStartMemoTagWrite(reminder.id) }, + onWriteTag = { tagId -> onStartMemoTagWrite(tagId) }, ) } 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 5bf4e42..df53c8a 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 @@ -85,6 +85,8 @@ import com.mapgie.dash.data.preferences.ThemeMode import com.mapgie.dash.notification.NotificationHelper import com.mapgie.dash.permission.PermissionHelper import android.app.NotificationManager +import com.mapgie.dash.nfc.NfcWriteRequest +import com.mapgie.dash.nfc.NfcWriteResult import com.mapgie.dash.ui.components.core.LocalReminderLabel import com.mapgie.dash.ui.components.core.PageHeader import com.mapgie.dash.ui.theme.AppTheme @@ -98,7 +100,7 @@ import java.io.InputStreamReader import kotlin.math.roundToInt enum class SettingsSubScreen { - NONE, CONNECTION, APPEARANCE, COLOURS, CATEGORIES, DISPLAY, QUICK_ADD, REMINDERS, WIDGET, ABOUT, HELP + NONE, CONNECTION, APPEARANCE, COLOURS, CATEGORIES, DISPLAY, QUICK_ADD, REMINDERS, WIDGET, TAGS, ABOUT, HELP } private const val CHANGELOG_URL = "https://github.com/mapgie/choreDash-Android/blob/main/CHANGELOG.md" @@ -116,6 +118,17 @@ fun SettingsScreen( onNavigateToLicenses: () -> Unit, pendingSubScreen: SettingsSubScreen? = null, onPendingSubScreenConsumed: () -> Unit = {}, + // NFC plumbing for Settings › NFC tags: identify a tag by scanning it, and + // write a chore's or tag-alarm's id to one. + nfcCapturedTagId: String? = null, + onStartNfcCapture: () -> Unit = {}, + onCancelNfcCapture: () -> Unit = {}, + onNfcCaptureConsumed: () -> Unit = {}, + tagWritePending: Boolean = false, + nfcWriteResult: NfcWriteResult? = null, + onStartTagWrite: (NfcWriteRequest) -> Unit = {}, + onCancelNfcWrite: () -> Unit = {}, + onNfcWriteResultConsumed: () -> Unit = {}, viewModel: SettingsViewModel = hiltViewModel() ) { var subScreen by rememberSaveable { mutableStateOf(SettingsSubScreen.NONE) } @@ -167,6 +180,18 @@ fun SettingsScreen( onBack = { subScreen = SettingsSubScreen.NONE }, viewModel = viewModel, ) + SettingsSubScreen.TAGS -> TagsSubScreen( + onBack = { subScreen = SettingsSubScreen.NONE }, + nfcCapturedTagId = nfcCapturedTagId, + onStartNfcCapture = onStartNfcCapture, + onCancelNfcCapture = onCancelNfcCapture, + onNfcCaptureConsumed = onNfcCaptureConsumed, + tagWritePending = tagWritePending, + nfcWriteResult = nfcWriteResult, + onStartTagWrite = onStartTagWrite, + onCancelNfcWrite = onCancelNfcWrite, + onNfcWriteResultConsumed = onNfcWriteResultConsumed, + ) SettingsSubScreen.ABOUT -> AboutSubScreen( onBack = { subScreen = SettingsSubScreen.NONE }, onNavigateToLicenses = onNavigateToLicenses, @@ -243,6 +268,12 @@ private fun SettingsMainList( subtitle = "Choose what your home-screen widget shows", onClick = { onNavigate(SettingsSubScreen.WIDGET) } ) + SettingsHairline() + SettingsNavRow( + title = "NFC tags", + subtitle = "Every tag the app knows: identify one, write one, unlink one", + onClick = { onNavigate(SettingsSubScreen.TAGS) } + ) } SettingsSectionLabel("Reminders") 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 new file mode 100644 index 0000000..6f8ec28 --- /dev/null +++ b/app/src/main/java/com/mapgie/dash/ui/screens/settings/TagsSubScreen.kt @@ -0,0 +1,244 @@ +package com.mapgie.dash.ui.screens.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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 +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.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 + +/** + * Settings › NFC tags: tag maintenance in one place. An Identify card reads + * whatever tag is held to the phone and says what the app makes of it (a + * chore, a tag-alarm, or nothing). Below it, every chore's tag and every + * tag-alarm, each with a Write chip that stamps its id on a blank sticker + * 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. + * + * 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. + */ +@Composable +internal fun TagsSubScreen( + onBack: () -> Unit, + nfcCapturedTagId: String?, + onStartNfcCapture: () -> Unit, + onCancelNfcCapture: () -> Unit, + onNfcCaptureConsumed: () -> Unit, + tagWritePending: Boolean, + nfcWriteResult: NfcWriteResult?, + onStartTagWrite: (NfcWriteRequest) -> Unit, + onCancelNfcWrite: () -> Unit, + onNfcWriteResultConsumed: () -> Unit, + viewModel: TagsViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsState() + val snackbarHost = remember { SnackbarHostState() } + + var scanning by rememberSaveable { mutableStateOf(false) } + // 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) } + + LaunchedEffect(uiState.error) { + uiState.error?.let { + snackbarHost.showSnackbar(it) + viewModel.clearError() + } + } + + // Capture follows [scanning]: asked when it turns on (again after rotation, + // which the activity forgets and saved state does not), withdrawn when it + // turns off or the page goes away. + LaunchedEffect(scanning) { + if (scanning) onStartNfcCapture() else onCancelNfcCapture() + } + DisposableEffect(Unit) { + onDispose { onCancelNfcCapture() } + } + LaunchedEffect(nfcCapturedTagId) { + val scanned = nfcCapturedTagId ?: return@LaunchedEffect + if (scanning) { + scanning = false + readId = scanned + } + onNfcCaptureConsumed() + } + + val readEntry = readId?.let { uiState.identify(it) } + + Scaffold( + snackbarHost = { SnackbarHost(snackbarHost) }, + topBar = { SubScreenHeader(title = "NFC tags", onBack = onBack) }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(innerPadding) + .padding(horizontal = 18.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + SettingsCaption("Every tag the app recognises. A tag has one job: it belongs to one chore or one tag-alarm.") + + SettingsSectionLabel("Identify a tag") + SettingsCard { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + .padding(vertical = 12.dp), + ) { + val (title, subtitle) = when { + scanning -> "Listening" to "Hold a tag to the back of your phone." + readId == null -> "Scan a tag" to "See which chore or tag-alarm it belongs to." + readEntry == null -> readId!! to "Not linked to anything. Open a chore or tag-alarm to link it." + else -> readId!! to "${readEntry.kind.label}: ${readEntry.name}" + + if (readEntry.archived) " (archived)" else "" + } + SettingsRowText( + title = title, + subtitle = subtitle, + modifier = Modifier + .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, + ) + } + } + + SettingsSectionLabel("Chores") + SettingsCard { + 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 { + uiState.choreTags.forEachIndexed { index, entry -> + if (index > 0) SettingsHairline() + SettingsCardRow( + title = entry.name, + subtitle = entry.tagId + if (entry.archived) " · archived" else "", + ) { + ValueChip( + text = "Write", + onClick = { onStartTagWrite(NfcWriteRequest(NfcWriteRequest.Kind.CHORE, entry.ownerId)) }, + contentDescription = "Write ${entry.name}'s id to a tag", + chevron = false, + ) + } + } + } + } + + SettingsSectionLabel("Tag-alarms") + SettingsCard { + if (uiState.tagAlarms.isEmpty()) { + SettingsCardRow(title = "No tag-alarms", subtitle = "Add one from the Memos tab with the Tag-alarm switch on.") + } else { + uiState.tagAlarms.forEachIndexed { index, entry -> + if (index > 0) SettingsHairline() + SettingsCardRow( + title = entry.name, + subtitle = entry.tagId ?: "No tag yet", + ) { + Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { + ValueChip( + text = "Write", + onClick = { + val tagId = entry.tagId + if (tagId != null) { + onStartTagWrite(NfcWriteRequest(NfcWriteRequest.Kind.MEMO, tagId)) + } else { + viewModel.assignTagThen(entry.ownerId, entry.name) { minted -> + onStartTagWrite(NfcWriteRequest(NfcWriteRequest.Kind.MEMO, minted)) + } + } + }, + contentDescription = "Write ${entry.name}'s tag id to a tag", + chevron = false, + ) + if (entry.tagId != null) { + ValueChip( + text = "Unlink", + onClick = { pendingUnlink = entry }, + contentDescription = "Unlink ${entry.name} from its tag", + chevron = false, + ) + } + } + } + } + } + } + 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.") + Spacer(Modifier.height(8.dp)) + } + } + + if (tagWritePending) { + WriteTagDialog( + result = nfcWriteResult, + onDismiss = { + if (nfcWriteResult != null) onNfcWriteResultConsumed() else onCancelNfcWrite() + } + ) + } + + pendingUnlink?.let { entry -> + AlertDialog( + onDismissRequest = { pendingUnlink = null }, + title = { Text("Unlink “${entry.name}”?") }, + text = { Text("Tapping its tag will no longer set this tag-alarm. The tag itself is untouched, so it can be written or linked again.") }, + confirmButton = { + TextButton(onClick = { + pendingUnlink = null + viewModel.unlink(entry.ownerId) + }) { Text("Unlink", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { + TextButton(onClick = { pendingUnlink = null }) { Text("Cancel") } + }, + ) + } +} 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 new file mode 100644 index 0000000..88e6193 --- /dev/null +++ b/app/src/main/java/com/mapgie/dash/ui/screens/settings/TagsViewModel.kt @@ -0,0 +1,125 @@ +package com.mapgie.dash.ui.screens.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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.repository.ChoreRepository +import com.mapgie.dash.data.repository.ReminderRepository +import com.mapgie.dash.data.supabase.userFacingMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** What owns an NFC tag id: a chore row in Supabase, or an on-device tag-alarm. */ +enum class TagOwnerKind(val label: String) { + CHORE("Chore"), + TAG_ALARM("Tag-alarm"), +} + +/** + * One line on Settings › NFC tags: the id a tag answers with (null for a + * tag-alarm that has no tag yet), what it belongs to, and that owner's id + * (a chore's tag id, a tag-alarm's record id) for the row's actions. + */ +data class TagEntry( + val tagId: String?, + val name: String, + val kind: TagOwnerKind, + val ownerId: String, + val archived: Boolean = false, +) + +/** + * 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 + * tag, and what a scanned id resolves to. + */ +data class TagsUiState( + val loading: Boolean = true, + val error: String? = null, + val chores: List = emptyList(), + val reminders: List = emptyList(), +) { + val choreTags: List + get() = chores + .map { TagEntry(it.tagId, it.label, TagOwnerKind.CHORE, it.tagId, archived = it.archivedAt != null) } + .sortedWith(compareBy({ it.archived }, { it.name.lowercase() })) + + val tagAlarms: List + get() = reminders + .filter { it.isTagAlarm && it.archivedAt == null } + .map { TagEntry(it.tagId, it.subject, TagOwnerKind.TAG_ALARM, it.id) } + .sortedBy { it.name.lowercase() } + + /** Every id some chore or tag-alarm answers to. */ + val takenTagIds: Set + get() = (choreTags + tagAlarms).mapNotNull { it.tagId }.toSet() + + /** What a scanned id belongs to, or null when nothing in the app knows it. */ + fun identify(tagId: String): TagEntry? = + (choreTags + tagAlarms).firstOrNull { it.tagId == tagId } + + /** A friendly, unused id for a tag-alarm called [subject], for a first write from this page. */ + fun freeTagIdFor(subject: String): String = freeTagId(subject, takenTagIds) +} + +@HiltViewModel +class TagsViewModel @Inject constructor( + private val choreRepository: ChoreRepository, + private val reminderRepository: ReminderRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(TagsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + // Tag-alarms are on-device and change under this page (a link written from + // the memo sheet, say), so they are followed rather than loaded once. + viewModelScope.launch { + reminderRepository.remindersFlow.collect { reminders -> + _uiState.update { it.copy(reminders = reminders) } + } + } + load() + } + + /** Loads the chores. A Supabase failure is reported but leaves the tag-alarms showing. */ + fun load() { + viewModelScope.launch { + _uiState.update { it.copy(loading = true, error = null) } + runCatching { choreRepository.load().let { it.active + it.archived } } + .onSuccess { chores -> _uiState.update { it.copy(loading = false, chores = chores) } } + .onFailure { e -> _uiState.update { it.copy(loading = false, error = e.userFacingMessage()) } } + } + } + + /** Unlinks a tag-alarm from its tag; the tag itself is untouched and can be written again. */ + fun unlink(memoId: String) { + viewModelScope.launch { + runCatching { reminderRepository.setTagAlarmTag(memoId, null) } + .onFailure { e -> _uiState.update { it.copy(error = e.userFacingMessage()) } } + } + } + + /** + * Gives a tag-alarm with no tag an id to write, then calls [then] with it. The + * id is minted from its name and kept clear of every id already in use. + */ + fun assignTagThen(memoId: String, subject: String, then: (String) -> Unit) { + val tagId = _uiState.value.freeTagIdFor(subject) + viewModelScope.launch { + runCatching { reminderRepository.setTagAlarmTag(memoId, tagId) } + .onSuccess { then(tagId) } + .onFailure { e -> _uiState.update { it.copy(error = e.userFacingMessage()) } } + } + } + + fun clearError() = _uiState.update { it.copy(error = null) } +} diff --git a/app/src/test/java/com/mapgie/dash/data/model/TagAlarmModelTest.kt b/app/src/test/java/com/mapgie/dash/data/model/TagAlarmModelTest.kt index 33972fd..c37ef86 100644 --- a/app/src/test/java/com/mapgie/dash/data/model/TagAlarmModelTest.kt +++ b/app/src/test/java/com/mapgie/dash/data/model/TagAlarmModelTest.kt @@ -248,6 +248,16 @@ class TagAlarmModelTest { assertEquals("", TagAlarmText.conflictQuestion(emptyList(), tue2200, zone)) } + @Test + fun `a friendly tag id is the name folded to lower-case letters, digits and hyphens`() { + assertEquals("office-a", suggestTagId("Office A")) + assertEquals("waterloo-office", suggestTagId(" Waterloo Office! ")) + assertEquals("memo", suggestTagId("???")) + assertEquals(40, suggestTagId("x".repeat(60)).length) + assertEquals("office-a-3", freeTagId("Office A", setOf("office-a", "office-a-2"))) + assertEquals("home", freeTagId("Home", setOf("office-a"))) + } + @Test fun `a plain memo is untouched by the tag-alarm rules`() { val memo = ReminderDto(id = "memo", subject = "memo", remindAt = "2026-07-08T09:00:00Z") 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 new file mode 100644 index 0000000..3266c7a --- /dev/null +++ b/app/src/test/java/com/mapgie/dash/ui/screens/settings/TagsUiStateTest.kt @@ -0,0 +1,68 @@ +package com.mapgie.dash.ui.screens.settings + +import com.mapgie.dash.data.model.Chore +import com.mapgie.dash.data.model.ChoreStatus +import com.mapgie.dash.data.model.ReminderDto +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * What Settings › NFC tags lists and resolves: every chore's tag with archived + * ones last, every unarchived tag-alarm with or without a tag, what a scanned + * id belongs to, and a free id for a tag-alarm's first write. + */ +class TagsUiStateTest { + + private fun chore(tagId: String, label: String, archived: Boolean = false) = Chore( + id = "c-$tagId", tagId = tagId, label = label, category = null, owner = null, + intervalDays = null, archivedAt = if (archived) "2026-07-01T00:00:00Z" else null, + lastScanned = null, lastScanId = null, status = ChoreStatus.NEVER, + ) + + private fun tagAlarm(id: String, subject: String, tagId: String?, archived: Boolean = false) = ReminderDto( + id = id, subject = subject, remindAt = "2026-07-08T05:15:00Z", tagAlarm = true, tagId = tagId, + ringTimes = listOf("05:15"), archivedAt = if (archived) "2026-07-01T00:00:00Z" else null, + ) + + private val state = TagsUiState( + loading = false, + chores = listOf(chore("laundry", "Laundry"), chore("bins", "Bins", archived = true), chore("car-service", "Car service")), + reminders = listOf( + tagAlarm("m1", "Office A", "office-a"), + tagAlarm("m2", "Home", null), + tagAlarm("m3", "Old office", "old-office", archived = true), + ReminderDto(id = "plain", subject = "Water plants", remindAt = "2026-07-08T09:00:00Z"), + ), + ) + + @Test + fun `chore tags list active ones A to Z, then archived`() { + assertEquals(listOf("Car service", "Laundry", "Bins"), state.choreTags.map { it.name }) + assertEquals(listOf(false, false, true), state.choreTags.map { it.archived }) + assertEquals("car-service", state.choreTags.first().ownerId) + } + + @Test + fun `tag-alarms list unarchived ones with or without a tag, never plain memos`() { + assertEquals(listOf("Home", "Office A"), state.tagAlarms.map { it.name }) + assertEquals(listOf(null, "office-a"), state.tagAlarms.map { it.tagId }) + assertEquals("m2", state.tagAlarms.first().ownerId) + } + + @Test + fun `identify names the chore or tag-alarm a scanned id belongs to`() { + assertEquals(TagOwnerKind.CHORE, state.identify("laundry")?.kind) + assertEquals("Office A", state.identify("office-a")?.name) + assertNull(state.identify("waterloo")) + // An archived tag-alarm's tag is no longer anyone's. + assertNull(state.identify("old-office")) + } + + @Test + fun `a free tag id comes from the name and steps around ids in use`() { + assertEquals("home", state.freeTagIdFor("Home")) + assertEquals("office-a-2", state.freeTagIdFor("Office A")) + assertEquals("laundry-2", state.freeTagIdFor("Laundry")) + } +} diff --git a/changelog/unreleased/friendly-tag-ids.json b/changelog/unreleased/friendly-tag-ids.json new file mode 100644 index 0000000..c285922 --- /dev/null +++ b/changelog/unreleased/friendly-tag-ids.json @@ -0,0 +1,7 @@ +{ + "bump": "minor", + "added": [ + "A tag-alarm's tag now has a friendly name: name it from the Tag row (\"Office A\" becomes \"office-a\"), or let the write use one made from the memo's title. A new tag-alarm can be written to a blank tag straight away, before it is saved.", + "Settings > NFC tags lists every tag the app recognises: each chore's tag and each tag-alarm. Scan a tag to see what it belongs to, write a chore's or tag-alarm's id to a blank sticker, give a tag-alarm an id, or unlink one." + ] +}