Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions app/src/main/java/com/mapgie/dash/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
24 changes: 24 additions & 0 deletions app/src/main/java/com/mapgie/dash/data/model/TagAlarm.kt
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,30 @@ fun ReminderDto.conflictingTagAlarms(all: List<ReminderDto>, 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>): 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 {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]).
Expand Down
7 changes: 4 additions & 3 deletions app/src/main/java/com/mapgie/dash/nfc/NfcHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ sealed class NfcWriteResult {

/**
* A tag the app is waiting to write: a chore's tag id (`chordash://tag?tag=<id>`)
* or a memo's own id (`chordash://memo?memo=<id>`). Both read back through
* or a tag-alarm's tag id (`chordash://memo?memo=<id>`). 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
Expand Down
113 changes: 98 additions & 15 deletions app/src/main/java/com/mapgie/dash/ui/components/AddReminderSheet.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -219,6 +223,7 @@ fun AddReminderSheet(
var scanning by rememberSaveable { mutableStateOf(false) }
var tagError by rememberSaveable { mutableStateOf<String?>(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) }
Expand Down Expand Up @@ -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()) {
Expand All @@ -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),
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<String, String>,
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) {
Expand Down
31 changes: 23 additions & 8 deletions app/src/main/java/com/mapgie/dash/ui/navigation/DashNavGraph.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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 = {
Expand All @@ -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") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -355,6 +355,7 @@ fun RemindersListScreen(
onStartScan = onStartNfcCapture,
onCancelScan = onCancelNfcCapture,
onScanConsumed = onNfcCaptureConsumed,
onWriteTag = { tagId -> onStartMemoTagWrite(tagId) },
)
}

Expand All @@ -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) },
)
}

Expand Down
Loading
Loading