android: the toolbar moves to the top, and the note says when it saved
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m45s
Android / Kotlin + Rust (APK) (push) Failing after 3m50s
Desktop (Tauri) / Tauri desktop (Linux) (push) Canceled after 4m41s
Desktop (Tauri) / Update manifest (push) Canceled after 0s

The capture sheet's drag handle cost a strip of screen and did nothing a back
gesture does not already do. The toolbar takes that strip instead, which is
where it belonged once one surface served both writing and 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.

The bottom is now the answer to "did that land". There is no save button —
writes are continuous, so a button offering to do what already happened would
be a lie with a tap attached — but that left nothing on screen saying the work
was safe. Not saved yet → Saving… → Edited just now is the whole lifecycle in
the corner, and someone who watches it once never has to be told that closing
a note keeps it. DateUtils formats the relative part, so plurals and
"yesterday" are not this app's problem to solve twice.

Shape: the screen keeps the sheet's rounded top and its gap below the status
bar, so opening a note still reads as something rising over the board. Full
height rather than a real ModalBottomSheet — 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.

Both content colours on the card are spelled out. 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. That is
the same default that made the last toolbar invisible in dark mode, latent in
two more places.

Also: the running LinearProgressIndicator is gone, since the corner line now
says the same thing without moving the text; and the SaveText comment in
BoardViewModel still claimed saves happened on close.
This commit is contained in:
2026-08-23 22:26:12 -04:00
parent ce6a1093a3
commit 9ea2a2f9b6
5 changed files with 285 additions and 152 deletions
@@ -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))) }
@@ -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,
@@ -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
@@ -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
@@ -41,6 +41,10 @@
<string name="editor_remove_label">Remove label</string>
<string name="editor_reminder">Set a reminder</string>
<string name="editor_more">More actions</string>
<string name="editor_saving">Saving…</string>
<string name="editor_unsaved">Not saved yet</string>
<string name="editor_edited">Edited %1$s</string>
<string name="editor_just_now">just now</string>
<string name="editor_pin">Pin</string>
<string name="editor_unpin">Unpin</string>
<string name="editor_labels">Labels…</string>