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
25 changes: 24 additions & 1 deletion app/src/main/java/com/mapgie/dash/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -223,12 +225,25 @@ class MainActivity : ComponentActivity() {
if (writeRequest != null) {
val tag = intent.getParcelableExtra<android.nfc.Tag>(NfcAdapter.EXTRA_TAG)
if (tag != null) {
nfcWriteResult = NfcHandler.writeUri(tag, writeRequest.uri)
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
}
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
Expand All @@ -240,6 +255,14 @@ class MainActivity : ComponentActivity() {
intent.getStringExtra(WIDGET_DESTINATION_EXTRA)?.let { pendingWidgetDestination = it }
}

private fun recordSticker(tagId: String) {
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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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<Preferences> 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<Map<String, Instant>> = 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()))
}
}

/** 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<String, Instant> = seen.first()

private fun decode(raw: String?): Map<String, Instant> =
raw?.let { runCatching { json.decodeFromString<Map<String, String>>(it) }.getOrNull() }
.orEmpty()
.mapNotNull { (id, at) -> runCatching { Instant.parse(at) }.getOrNull()?.let { id to it } }
.toMap()
}
32 changes: 28 additions & 4 deletions app/src/main/java/com/mapgie/dash/nfc/NfcHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -721,7 +721,7 @@ fun AddReminderSheet(
onDraftClear()
onSave(buildInsert(tagOverride = writeId))
sheetScope.launch { sheetState.hide() }.invokeOnCompletion {
onWriteTag(writeId)
onWriteTag(writeId, null)
onDismiss()
}
},
Expand Down Expand Up @@ -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()
}
},
)

Expand Down Expand Up @@ -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 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,23 +25,27 @@ 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,
/** Why this write is happening, shown above the hold-the-tag line while waiting. */
note: String? = null,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Write tag") },
title = { Text(if (erasing) "Erase tag" else "Write tag") },
text = {
when (result) {
null -> Row {
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 }
)
}
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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String?>(null) }
// Why the pending tag write is happening (a renamed tag), for the write dialog.
var writeNote by rememberSaveable { mutableStateOf<String?>(null) }

LaunchedEffect(pendingAddIntent) {
if (pendingAddIntent == AddMenuOption.REMINDER) {
Expand Down Expand Up @@ -355,7 +357,7 @@ fun RemindersListScreen(
onStartScan = onStartNfcCapture,
onCancelScan = onCancelNfcCapture,
onScanConsumed = onNfcCaptureConsumed,
onWriteTag = { tagId -> onStartMemoTagWrite(tagId) },
onWriteTag = { tagId, note -> writeNote = note; onStartMemoTagWrite(tagId) },
)
}

Expand All @@ -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()
}
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ fun SettingsScreen(
onCancelNfcCapture: () -> Unit = {},
onNfcCaptureConsumed: () -> Unit = {},
tagWritePending: Boolean = false,
tagErasePending: Boolean = false,
nfcWriteResult: NfcWriteResult? = null,
onStartTagWrite: (NfcWriteRequest) -> Unit = {},
onCancelNfcWrite: () -> Unit = {},
Expand Down Expand Up @@ -187,6 +188,7 @@ fun SettingsScreen(
onCancelNfcCapture = onCancelNfcCapture,
onNfcCaptureConsumed = onNfcCaptureConsumed,
tagWritePending = tagWritePending,
tagErasePending = tagErasePending,
nfcWriteResult = nfcWriteResult,
onStartTagWrite = onStartTagWrite,
onCancelNfcWrite = onCancelNfcWrite,
Expand Down
Loading
Loading