android: the toolbar moves to the top, and the note says when it saved
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:
@@ -356,8 +356,9 @@ class BoardViewModel(
|
|||||||
EditorAction.Close -> state = state.copy(editing = null)
|
EditorAction.Close -> state = state.copy(editing = null)
|
||||||
EditorAction.DismissError -> dismissError()
|
EditorAction.DismissError -> dismissError()
|
||||||
|
|
||||||
// Saved on close rather than per keystroke, so a session of typing
|
// Sent on an idle debounce while typing, and again on close. Writing
|
||||||
// costs one write and one revision snapshot.
|
// 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 ->
|
is EditorAction.SaveText ->
|
||||||
mutate { it.updateNote(id, listOf(NoteEdit.Body(action.body))) }
|
mutate { it.updateNote(id, listOf(NoteEdit.Body(action.body))) }
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.fabledsword.thoughtsync.ui
|
package com.fabledsword.thoughtsync.ui
|
||||||
|
|
||||||
|
import android.text.format.DateUtils
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
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.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
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.automirrored.filled.List
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
import androidx.compose.material3.BottomAppBar
|
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -26,6 +29,8 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -40,7 +45,19 @@ import com.fabledsword.thoughtsync.R
|
|||||||
import com.fabledsword.thoughtsync.core.Note
|
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
|
* The three affordances with a permanent slot are the ones reached for while still
|
||||||
* writing — colour, reminder, note-or-list. Everything structural (pin, labels,
|
* writing — colour, reminder, note-or-list. Everything structural (pin, labels,
|
||||||
@@ -55,77 +72,157 @@ import com.fabledsword.thoughtsync.core.Note
|
|||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun EditorBottomBar(
|
fun EditorTopBar(
|
||||||
note: Note,
|
note: Note,
|
||||||
readOnly: Boolean,
|
readOnly: Boolean,
|
||||||
tint: NoteTint,
|
tint: NoteTint,
|
||||||
|
onClose: () -> Unit,
|
||||||
onPicker: (Picker) -> Unit,
|
onPicker: (Picker) -> Unit,
|
||||||
onConfirmDelete: () -> Unit,
|
onConfirmDelete: () -> Unit,
|
||||||
onAction: (EditorAction) -> Unit,
|
onAction: (EditorAction) -> Unit,
|
||||||
) {
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
BottomAppBar(
|
TopAppBar(
|
||||||
// imePadding so the bar rides above the keyboard. `enableEdgeToEdge` makes
|
title = {},
|
||||||
// the manifest's adjustResize a no-op, and Scaffold does not inset its
|
navigationIcon = {
|
||||||
// bottomBar slot for the IME — without this the bar sits under the keyboard
|
// The only way out, and the only thing that needed a "save" button
|
||||||
// the moment anyone types. The content Column deliberately does NOT also
|
// before writes became continuous. Leaving IS saving now, which is what
|
||||||
// add imePadding: Scaffold measures this bar at its padded height, so the
|
// the line in the bottom corner is there to say out loud.
|
||||||
// inset already reaches the content through innerPadding.
|
IconButton(onClick = onClose) {
|
||||||
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) }) {
|
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Notifications,
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
contentDescription = stringResource(R.string.editor_reminder),
|
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
|
actions = {
|
||||||
// left to add that the checklist's own "+" row doesn't do better.
|
if (!readOnly) {
|
||||||
if (note.items.isEmpty()) {
|
// A dot in the note's CURRENT colour rather than a palette icon: it
|
||||||
IconButton(onClick = { onAction(EditorAction.AddChecklist) }) {
|
// shows what the colour is as well as what the button does.
|
||||||
Icon(
|
IconButton(onClick = { onPicker(Picker.COLOR) }) {
|
||||||
Icons.AutoMirrored.Filled.List,
|
Box(
|
||||||
contentDescription = stringResource(R.string.editor_add_checklist),
|
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))
|
/**
|
||||||
|
* When the note was last written, in the bottom corner.
|
||||||
OverflowMenu(
|
*
|
||||||
note = note,
|
* There is no save button. That is right — a note is saved continuously, so a
|
||||||
readOnly = readOnly,
|
* button offering to do what already happened is a lie with a tap attached — but it
|
||||||
onPicker = onPicker,
|
* leaves nothing on screen saying the work is safe, and "closing this saves it" is
|
||||||
onConfirmDelete = onConfirmDelete,
|
* not a thing anyone should have to be told twice.
|
||||||
onAction = onAction,
|
*
|
||||||
|
* 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
|
@Composable
|
||||||
private fun OverflowMenu(
|
private fun OverflowMenu(
|
||||||
note: Note,
|
note: Note,
|
||||||
|
|||||||
@@ -3,25 +3,23 @@ package com.fabledsword.thoughtsync.ui
|
|||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
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.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
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.AlertDialog
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -42,16 +40,21 @@ import com.fabledsword.thoughtsync.core.Note
|
|||||||
import kotlinx.coroutines.delay
|
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
|
* Shaped like the capture sheet it replaced — a rounded card that begins below the
|
||||||
* thought landed somewhere. Editing is different — a sustained task with the
|
* status bar — so opening a note still reads as something rising over the board
|
||||||
* keyboard up — and a sheet would spend the whole time fighting the IME for the
|
* rather than a place you navigated to. It is full height rather than a real
|
||||||
* bottom half of the display. Full screen also gives the actions a bottom bar,
|
* `ModalBottomSheet`, and that is the whole trade: a sheet spends a writing session
|
||||||
* which is where a thumb already is.
|
* 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.
|
* 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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -142,87 +145,104 @@ fun NoteEditorScreen(
|
|||||||
// is exactly the failure that makes someone stop trusting a notes app.
|
// is exactly the failure that makes someone stop trusting a notes app.
|
||||||
FlushOnStop(flush)
|
FlushOnStop(flush)
|
||||||
|
|
||||||
Scaffold(
|
// The sheet shape, kept. `windowInsetsPadding` both insets the card below the
|
||||||
containerColor = tint.background(dark),
|
// status bar AND consumes that inset, so the bar inside adds no second gap of
|
||||||
topBar = {
|
// its own — the strip above the rounded corner is what makes this read as a card
|
||||||
TopAppBar(
|
// over the board rather than a screen that replaced it.
|
||||||
title = {},
|
Box(
|
||||||
navigationIcon = {
|
modifier =
|
||||||
IconButton(onClick = leave) {
|
Modifier
|
||||||
Icon(
|
.fillMaxSize()
|
||||||
Icons.AutoMirrored.Filled.ArrowBack,
|
.windowInsetsPadding(WindowInsets.statusBars),
|
||||||
contentDescription = stringResource(R.string.editor_back),
|
) {
|
||||||
|
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
|
// One field. A note is its body; its NAME is that body's first
|
||||||
// banner, but a write that fails while the editor is open would
|
// line, so there is nothing separate to type into and nothing to
|
||||||
// otherwise report itself only after the user had already left.
|
// render bolder than the line beneath it (M13 steps 3 and 4).
|
||||||
error?.let { message ->
|
EditorField(
|
||||||
ErrorBanner(message = message, onDismiss = { onAction(EditorAction.DismissError) })
|
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
|
// Below the body, not instead of it, and only once the note has
|
||||||
// there is nothing separate to type into and nothing to render bolder
|
// items — the toolbar's add-checklist action is what puts the
|
||||||
// than the line beneath it (M13 steps 3 and 4).
|
// first one there.
|
||||||
EditorField(
|
if (note.items.isNotEmpty()) {
|
||||||
value = body,
|
ChecklistEditor(note = note, readOnly = readOnly, onAction = onAction)
|
||||||
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 —
|
if (note.labels.isNotEmpty()) {
|
||||||
// the toolbar's add-checklist action is what puts the first one there.
|
EditorLabelRow(note = note, readOnly = readOnly, onAction = onAction)
|
||||||
if (note.items.isNotEmpty()) {
|
}
|
||||||
ChecklistEditor(note = note, readOnly = readOnly, onAction = onAction)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (note.labels.isNotEmpty()) {
|
note.remindAt?.let { at ->
|
||||||
EditorLabelRow(note = note, readOnly = readOnly, onAction = onAction)
|
EditorReminderRow(
|
||||||
}
|
at = at,
|
||||||
|
recurrence = note.recurrence,
|
||||||
note.remindAt?.let { at ->
|
readOnly = readOnly,
|
||||||
EditorReminderRow(
|
onAction = onAction,
|
||||||
at = at,
|
)
|
||||||
recurrence = note.recurrence,
|
}
|
||||||
readOnly = readOnly,
|
}
|
||||||
onAction = onAction,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,4 +359,10 @@ private fun EditorField(
|
|||||||
*/
|
*/
|
||||||
private const val AUTOSAVE_IDLE_MS = 1_000L
|
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
|
private const val MIN_BODY_LINES = 6
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.fabledsword.thoughtsync.ui
|
package com.fabledsword.thoughtsync.ui
|
||||||
|
|
||||||
import java.time.Instant
|
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.time.ZoneId
|
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. */
|
/** Whether a stored reminder has already passed, for showing it as overdue. */
|
||||||
fun isPast(raw: String): Boolean =
|
fun isPast(raw: String): Boolean {
|
||||||
runCatching { OffsetDateTime.parse(raw).toInstant() < Instant.now() }.getOrDefault(false)
|
val at = epochMillis(raw) ?: return false
|
||||||
|
return at < System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a timestamp is older than [minutes] ago — or absent entirely.
|
* Whether a timestamp is older than [minutes] ago — or absent entirely.
|
||||||
@@ -83,8 +88,8 @@ fun olderThan(
|
|||||||
raw: String?,
|
raw: String?,
|
||||||
minutes: Long,
|
minutes: Long,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val at = raw?.let { runCatching { OffsetDateTime.parse(it).toInstant() }.getOrNull() }
|
val at = raw?.let { epochMillis(it) }
|
||||||
return at == null || at < Instant.now().minusSeconds(minutes * SECONDS_PER_MINUTE)
|
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_remove_label">Remove label</string>
|
||||||
<string name="editor_reminder">Set a reminder</string>
|
<string name="editor_reminder">Set a reminder</string>
|
||||||
<string name="editor_more">More actions</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_pin">Pin</string>
|
||||||
<string name="editor_unpin">Unpin</string>
|
<string name="editor_unpin">Unpin</string>
|
||||||
<string name="editor_labels">Labels…</string>
|
<string name="editor_labels">Labels…</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user