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…