diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt index eb18701..2d45f3d 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt @@ -356,8 +356,9 @@ class BoardViewModel( EditorAction.Close -> state = state.copy(editing = null) EditorAction.DismissError -> dismissError() - // Saved on close rather than per keystroke, so a session of typing - // costs one write and one revision snapshot. + // Sent on an idle debounce while typing, and again on close. Writing + // this often is affordable because a body write no longer snapshots a + // revision — the core keeps one per editing session, not one per save. is EditorAction.SaveText -> mutate { it.updateNote(id, listOf(NoteEdit.Body(action.body))) } diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt index f77044b..4a57648 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/EditorChrome.kt @@ -1,5 +1,6 @@ package com.fabledsword.thoughtsync.ui +import android.text.format.DateUtils import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -8,16 +9,18 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Notifications -import androidx.compose.material3.BottomAppBar import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -26,6 +29,8 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -40,7 +45,19 @@ import com.fabledsword.thoughtsync.R import com.fabledsword.thoughtsync.core.Note /** - * The editor's action bar, at the bottom where a thumb already is. + * The editor's action bar, along the top of the surface. + * + * It sits exactly where the capture sheet's drag handle used to. The handle cost + * this strip of screen and did nothing that a back gesture does not already do, so + * the strip carries the actions instead. + * + * Top rather than bottom, now that this one surface is used for WRITING as well as + * editing: the keyboard owns the bottom of the display for most of a note's life, + * so a bar down there spends its time riding on the IME. That is the right place + * for a send button and the wrong one for a colour picker, which is reached for + * between thoughts rather than at the end of them. The cost is honest — the top of + * a phone is further from a thumb than the bottom — and it buys a bar that does not + * move while you type. * * The three affordances with a permanent slot are the ones reached for while still * writing — colour, reminder, note-or-list. Everything structural (pin, labels, @@ -55,77 +72,157 @@ import com.fabledsword.thoughtsync.core.Note */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun EditorBottomBar( +fun EditorTopBar( note: Note, readOnly: Boolean, tint: NoteTint, + onClose: () -> Unit, onPicker: (Picker) -> Unit, onConfirmDelete: () -> Unit, onAction: (EditorAction) -> Unit, ) { val dark = isSystemInDarkTheme() - BottomAppBar( - // imePadding so the bar rides above the keyboard. `enableEdgeToEdge` makes - // the manifest's adjustResize a no-op, and Scaffold does not inset its - // bottomBar slot for the IME — without this the bar sits under the keyboard - // the moment anyone types. The content Column deliberately does NOT also - // add imePadding: Scaffold measures this bar at its padded height, so the - // inset already reaches the content through innerPadding. - modifier = Modifier.imePadding(), - containerColor = tint.background(dark), - // EXPLICIT, and not optional. The default is contentColorFor(containerColor), - // which maps a colour-SCHEME ROLE to its `on-` pair and returns Unspecified - // for anything else. A note tint is never a role — the default note is - // 0xFF171717 while the dark scheme's surface is 0xFF0A0A0A — so the default - // resolved to Unspecified, Surface published that as LocalContentColor, and - // Icon drew with no colour filter: black vectors on a near-black bar. The - // toolbar was rendering the whole time and was invisible in dark mode. - contentColor = MaterialTheme.colorScheme.onSurface, - ) { - if (!readOnly) { - // A dot in the note's CURRENT colour rather than a palette icon: it - // shows what the colour is as well as what the button does. - IconButton(onClick = { onPicker(Picker.COLOR) }) { - Box( - modifier = - Modifier - .size(SWATCH_DOT) - .clip(CircleShape) - .background(tint.chipBackground(dark)) - .border(1.dp, tint.border(dark), CircleShape), - ) - } - IconButton(onClick = { onPicker(Picker.REMINDER) }) { + TopAppBar( + title = {}, + navigationIcon = { + // The only way out, and the only thing that needed a "save" button + // before writes became continuous. Leaving IS saving now, which is what + // the line in the bottom corner is there to say out loud. + IconButton(onClick = onClose) { Icon( - Icons.Filled.Notifications, - contentDescription = stringResource(R.string.editor_reminder), + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.editor_back), ) } - // Adds the first checklist item, which is what makes the checklist - // editor appear. Hidden once the note already has one — there is nothing - // left to add that the checklist's own "+" row doesn't do better. - if (note.items.isEmpty()) { - IconButton(onClick = { onAction(EditorAction.AddChecklist) }) { - Icon( - Icons.AutoMirrored.Filled.List, - contentDescription = stringResource(R.string.editor_add_checklist), + }, + actions = { + if (!readOnly) { + // A dot in the note's CURRENT colour rather than a palette icon: it + // shows what the colour is as well as what the button does. + IconButton(onClick = { onPicker(Picker.COLOR) }) { + Box( + modifier = + Modifier + .size(SWATCH_DOT) + .clip(CircleShape) + .background(tint.chipBackground(dark)) + .border(1.dp, tint.border(dark), CircleShape), ) } + IconButton(onClick = { onPicker(Picker.REMINDER) }) { + Icon( + Icons.Filled.Notifications, + contentDescription = stringResource(R.string.editor_reminder), + ) + } + // Adds the first checklist item, which is what makes the checklist + // editor appear. Hidden once the note already has one — there is + // nothing left to add that the checklist's own "+" row doesn't do + // better. + if (note.items.isEmpty()) { + IconButton(onClick = { onAction(EditorAction.AddChecklist) }) { + Icon( + Icons.AutoMirrored.Filled.List, + contentDescription = stringResource(R.string.editor_add_checklist), + ) + } + } } - } + OverflowMenu( + note = note, + readOnly = readOnly, + onPicker = onPicker, + onConfirmDelete = onConfirmDelete, + onAction = onAction, + ) + }, + // EXPLICIT, and not optional — the same lesson the old bottom bar learned. + // Material derives a bar's content colour from its container via + // contentColorFor(), which maps a colour-SCHEME ROLE to its `on-` pair and + // returns Unspecified for anything else. A note tint is never a role, so the + // icons drew with no colour filter: black vectors on a near-black bar, a + // toolbar that rendered the whole time and was invisible in dark mode. + // + // onSurface for the actions too, not the default onSurfaceVariant: these sit + // on a tinted bar rather than a scheme surface, and the muted variant does + // not have the contrast to spare. + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = tint.background(dark), + navigationIconContentColor = MaterialTheme.colorScheme.onSurface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.onSurface, + ), + ) +} - Box(modifier = Modifier.weight(1f)) - - OverflowMenu( - note = note, - readOnly = readOnly, - onPicker = onPicker, - onConfirmDelete = onConfirmDelete, - onAction = onAction, +/** + * When the note was last written, in the bottom corner. + * + * There is no save button. That is right — a note is saved continuously, so a + * button offering to do what already happened is a lie with a tap attached — but it + * leaves nothing on screen saying the work is safe, and "closing this saves it" is + * not a thing anyone should have to be told twice. + * + * So the state says it instead, and it says it as a fact rather than an + * instruction. Not saved yet → Saving… → Edited just now is the whole lifecycle, + * visible in the corner, and someone who watches it once never has to wonder again. + * + * [DateUtils] rather than a hand-rolled formatter: it is localised, it already + * knows the difference between minutes, hours and yesterday, and getting plurals + * right in every language is not this app's problem to solve twice. + */ +@Composable +fun EditorSavedLine( + updatedAt: String?, + saving: Boolean, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + // Rides above the keyboard, like the bar it replaced. The content + // Column deliberately does not also inset for the IME: Scaffold + // measures this at its lifted height and passes the inset down. + .imePadding() + .navigationBarsPadding() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.End, + ) { + Text( + text = savedLabel(updatedAt, saving), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } +/** The three things [EditorSavedLine] can be saying, in the order it says them. */ +@Composable +private fun savedLabel( + updatedAt: String?, + saving: Boolean, +): String { + // No timestamp means no row yet — a draft opened by + and not typed into. + val at = updatedAt?.let { epochMillis(it) } + val now = System.currentTimeMillis() + return when { + saving -> stringResource(R.string.editor_saving) + at == null -> stringResource(R.string.editor_unsaved) + // DateUtils rounds anything under its minimum resolution to "0 minutes + // ago" — which is both odd-looking and precisely the moment this line is + // on screen for, since it is the moment right after a save lands. + now - at < DateUtils.MINUTE_IN_MILLIS -> + stringResource(R.string.editor_edited, stringResource(R.string.editor_just_now)) + else -> + stringResource( + R.string.editor_edited, + DateUtils.getRelativeTimeSpanString(at, now, DateUtils.MINUTE_IN_MILLIS).toString(), + ) + } +} + @Composable private fun OverflowMenu( note: Note, diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt index 6edbf02..d2d6289 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt @@ -3,25 +3,23 @@ package com.fabledsword.thoughtsync.ui import androidx.activity.compose.BackHandler import androidx.annotation.StringRes import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -42,16 +40,21 @@ import com.fabledsword.thoughtsync.core.Note import kotlinx.coroutines.delay /** - * The note editor: a full screen, not a sheet. + * The one writing surface: a new note and an existing one are the same screen. * - * A sheet works for capture, where the board behind it is reassurance that the - * thought landed somewhere. Editing is different — a sustained task with the - * keyboard up — and a sheet would spend the whole time fighting the IME for the - * bottom half of the display. Full screen also gives the actions a bottom bar, - * which is where a thumb already is. + * Shaped like the capture sheet it replaced — a rounded card that begins below the + * status bar — so opening a note still reads as something rising over the board + * rather than a place you navigated to. It is full height rather than a real + * `ModalBottomSheet`, and that is the whole trade: a sheet spends a writing session + * negotiating with the IME for the bottom half of the display, and the swipe-down it + * buys is a gesture back already does. The shape is what was worth keeping. * - * The note's own colour paints the WHOLE screen rather than a card inside it, so + * The note's own colour paints the WHOLE card rather than a panel inside it, so * opening a note reads as the same object growing to fill the display. + * + * No save button, deliberately. Writes are continuous, so a button offering to do + * what already happened would be a lie with a tap attached; [EditorSavedLine] in the + * bottom corner says the same thing as a fact instead. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -142,87 +145,104 @@ fun NoteEditorScreen( // is exactly the failure that makes someone stop trusting a notes app. FlushOnStop(flush) - Scaffold( - containerColor = tint.background(dark), - topBar = { - TopAppBar( - title = {}, - navigationIcon = { - IconButton(onClick = leave) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.editor_back), + // The sheet shape, kept. `windowInsetsPadding` both insets the card below the + // status bar AND consumes that inset, so the bar inside adds no second gap of + // its own — the strip above the rounded corner is what makes this read as a card + // over the board rather than a screen that replaced it. + Box( + modifier = + Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.statusBars), + ) { + Surface( + modifier = Modifier.fillMaxSize(), + shape = RoundedCornerShape(topStart = SHEET_CORNER, topEnd = SHEET_CORNER), + color = tint.background(dark), + // Both content colours are spelled out for the reason the toolbar had to + // be: Surface and Scaffold each default theirs to contentColorFor(their + // container), which returns Unspecified for anything that is not a + // colour-SCHEME ROLE. A note tint never is, so the default publishes + // Unspecified as LocalContentColor and everything inside that does not + // set its own colour draws black — which is how the last toolbar became + // invisible in dark mode. + contentColor = MaterialTheme.colorScheme.onSurface, + ) { + Scaffold( + containerColor = tint.background(dark), + contentColor = MaterialTheme.colorScheme.onSurface, + topBar = { + EditorTopBar( + note = note, + readOnly = readOnly, + tint = tint, + onClose = leave, + onPicker = { picker = it }, + onConfirmDelete = { confirmingDelete = true }, + onAction = onAction, + ) + }, + // Where the action bar used to be. A note is saved as it is written, + // so what belongs at the bottom of the screen is not a control but + // the answer to "did that land" — see [EditorSavedLine]. + bottomBar = { EditorSavedLine(updatedAt = note.updatedAt, saving = saving) }, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + // No imePadding here: EditorSavedLine carries it, so + // Scaffold measures that row at its keyboard-lifted + // height and the inset already reaches this Column + // through `padding`. Adding it again would inset for the + // keyboard twice. + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + ) { + // A failed save has to be visible HERE. The board renders the + // same banner, but a write that fails while the editor is open + // would otherwise report itself only after the user had already + // left. + error?.let { message -> + ErrorBanner( + message = message, + onDismiss = { onAction(EditorAction.DismissError) }, ) } - }, - colors = TopAppBarDefaults.topAppBarColors(containerColor = tint.background(dark)), - ) - }, - bottomBar = { - EditorBottomBar( - note = note, - readOnly = readOnly, - tint = tint, - onPicker = { picker = it }, - onConfirmDelete = { confirmingDelete = true }, - onAction = onAction, - ) - }, - ) { padding -> - Column( - modifier = - Modifier - .fillMaxSize() - // No imePadding here: EditorBottomBar carries it, so Scaffold - // measures that bar at its keyboard-lifted height and the inset - // already reaches this Column through `padding`. Adding it again - // would inset for the keyboard twice. - .padding(padding) - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp), - ) { - // A one-pixel line, not a spinner: a save slow enough to see is worth - // showing, and one that isn't must not make the screen jump. - if (saving) { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - } - // A failed save has to be visible HERE. The board renders the same - // banner, but a write that fails while the editor is open would - // otherwise report itself only after the user had already left. - error?.let { message -> - ErrorBanner(message = message, onDismiss = { onAction(EditorAction.DismissError) }) - } + // One field. A note is its body; its NAME is that body's first + // line, so there is nothing separate to type into and nothing to + // render bolder than the line beneath it (M13 steps 3 and 4). + EditorField( + value = body, + onValueChange = { body = it }, + hint = R.string.editor_body_hint, + enabled = !readOnly, + minLines = MIN_BODY_LINES, + modifier = Modifier.focusRequester(bodyFocus), + ) - // One field. A note is its body; its NAME is that body's first line, so - // there is nothing separate to type into and nothing to render bolder - // than the line beneath it (M13 steps 3 and 4). - EditorField( - value = body, - onValueChange = { body = it }, - hint = R.string.editor_body_hint, - enabled = !readOnly, - minLines = MIN_BODY_LINES, - modifier = Modifier.focusRequester(bodyFocus), - ) + // Below the body, not instead of it, and only once the note has + // items — the toolbar's add-checklist action is what puts the + // first one there. + if (note.items.isNotEmpty()) { + ChecklistEditor(note = note, readOnly = readOnly, onAction = onAction) + } - // Below the body, not instead of it, and only once the note has items — - // the toolbar's add-checklist action is what puts the first one there. - if (note.items.isNotEmpty()) { - ChecklistEditor(note = note, readOnly = readOnly, onAction = onAction) - } + if (note.labels.isNotEmpty()) { + EditorLabelRow(note = note, readOnly = readOnly, onAction = onAction) + } - if (note.labels.isNotEmpty()) { - EditorLabelRow(note = note, readOnly = readOnly, onAction = onAction) - } - - note.remindAt?.let { at -> - EditorReminderRow( - at = at, - recurrence = note.recurrence, - readOnly = readOnly, - onAction = onAction, - ) + note.remindAt?.let { at -> + EditorReminderRow( + at = at, + recurrence = note.recurrence, + readOnly = readOnly, + onAction = onAction, + ) + } + } } } } @@ -339,4 +359,10 @@ private fun EditorField( */ private const val AUTOSAVE_IDLE_MS = 1_000L +/** + * The card's top corner radius — Material's extra-large, which is what a bottom + * sheet uses. Same shape as the capture surface this replaced, on purpose. + */ +private val SHEET_CORNER = 28.dp + private const val MIN_BODY_LINES = 6 diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/Time.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/Time.kt index 728f615..45ea2c4 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/Time.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/Time.kt @@ -1,6 +1,5 @@ package com.fabledsword.thoughtsync.ui -import java.time.Instant import java.time.LocalDateTime import java.time.OffsetDateTime import java.time.ZoneId @@ -67,9 +66,15 @@ fun reminderLabel( } } +/** A stored instant as epoch milliseconds, or null if it will not parse. */ +fun epochMillis(raw: String): Long? = + runCatching { OffsetDateTime.parse(raw).toInstant().toEpochMilli() }.getOrNull() + /** Whether a stored reminder has already passed, for showing it as overdue. */ -fun isPast(raw: String): Boolean = - runCatching { OffsetDateTime.parse(raw).toInstant() < Instant.now() }.getOrDefault(false) +fun isPast(raw: String): Boolean { + val at = epochMillis(raw) ?: return false + return at < System.currentTimeMillis() +} /** * Whether a timestamp is older than [minutes] ago — or absent entirely. @@ -83,8 +88,8 @@ fun olderThan( raw: String?, minutes: Long, ): Boolean { - val at = raw?.let { runCatching { OffsetDateTime.parse(it).toInstant() }.getOrNull() } - return at == null || at < Instant.now().minusSeconds(minutes * SECONDS_PER_MINUTE) + val at = raw?.let { epochMillis(it) } + return at == null || at < System.currentTimeMillis() - minutes * MILLIS_PER_MINUTE } -private const val SECONDS_PER_MINUTE = 60L +private const val MILLIS_PER_MINUTE = 60_000L diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index d859638..0316b69 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -41,6 +41,10 @@ Remove label Set a reminder More actions + Saving… + Not saved yet + Edited %1$s + just now Pin Unpin Labels…