Checklists in the body, colour from tags, and commit-derived CalVer #4
@@ -26,7 +26,6 @@ import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||
import com.fabledsword.thoughtsync.ui.BoardScreen
|
||||
import com.fabledsword.thoughtsync.ui.BoardSync
|
||||
import com.fabledsword.thoughtsync.ui.BoardViewModel
|
||||
import com.fabledsword.thoughtsync.ui.ComposeSheet
|
||||
import com.fabledsword.thoughtsync.ui.ForegroundTransitions
|
||||
import com.fabledsword.thoughtsync.ui.NoteEditorScreen
|
||||
import com.fabledsword.thoughtsync.ui.StoreUnavailableScreen
|
||||
@@ -141,14 +140,13 @@ private fun App(
|
||||
val sync: SyncViewModel =
|
||||
viewModel(factory = SyncViewModel.factory(core, onStoreChanged = board::refresh))
|
||||
|
||||
// Sheet and screen visibility are view STATE, not view-model state: they are
|
||||
// about what is on the display, and nothing in the store cares.
|
||||
// Screen visibility is view STATE, not view-model state: it is about what is on
|
||||
// the display, and nothing in the store cares. Saveable so a rotation does not
|
||||
// close it.
|
||||
//
|
||||
// Saveable, though: `remember` alone meant rotating the phone closed whatever
|
||||
// was open and took the half-written note in the capture sheet with it. The
|
||||
// editor never had that problem because the note it is on lives in a view
|
||||
// model; these two are the only screen state that did not.
|
||||
var composing by rememberSaveable { mutableStateOf(false) }
|
||||
// The capture sheet used to keep its own flag here too. It is gone: the + button
|
||||
// opens the editor on an unsaved draft, so writing a note and editing one are the
|
||||
// same surface with the same toolbar.
|
||||
var showingSync by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val update: UpdateViewModel = viewModel(factory = UpdateViewModel.factory(core, context))
|
||||
@@ -192,6 +190,7 @@ private fun App(
|
||||
NoteEditorScreen(
|
||||
// Non-null by construction: `screen` is EDITOR only when it is.
|
||||
note = requireNotNull(editing) { "the editor screen needs a note" },
|
||||
sessionKey = board.state.editingSession,
|
||||
labels = board.state.labels,
|
||||
saving = board.state.saving,
|
||||
error = board.state.error,
|
||||
@@ -218,20 +217,9 @@ private fun App(
|
||||
),
|
||||
onOpenSync = { showingSync = true },
|
||||
onSearch = board::search,
|
||||
onCompose = { composing = true },
|
||||
onCompose = board::compose,
|
||||
onDismissError = board::dismissError,
|
||||
)
|
||||
|
||||
if (composing) {
|
||||
ComposeSheet(
|
||||
saving = board.state.saving,
|
||||
onDismiss = { composing = false },
|
||||
onSave = { content ->
|
||||
board.create(content)
|
||||
composing = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,16 @@ data class BoardState(
|
||||
* has to re-query to see its own change.
|
||||
*/
|
||||
val editing: Note? = null,
|
||||
/**
|
||||
* Bumped each time the editor is opened on a DIFFERENT note, and deliberately
|
||||
* not when the note it is already on changes.
|
||||
*
|
||||
* The editor keys its text field on this rather than on `editing.id`, because a
|
||||
* draft's id changes the instant it is first saved — and re-keying on that would
|
||||
* reset the field to whatever the store just returned, discarding anything typed
|
||||
* during the write. That is a data-loss bug rather than a flicker.
|
||||
*/
|
||||
val editingSession: Long = 0,
|
||||
) {
|
||||
/** Search overrides the destination while there is a query to run. */
|
||||
val searching: Boolean get() = query.isNotBlank()
|
||||
@@ -188,44 +198,6 @@ class BoardViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a new note or list.
|
||||
*
|
||||
* Blank input is ignored rather than rejected: an empty save is a slip, not a
|
||||
* mistake worth interrupting someone over.
|
||||
*/
|
||||
fun create(content: String) {
|
||||
val cleanContent = content.trim()
|
||||
if (cleanContent.isEmpty()) return
|
||||
|
||||
viewModelScope.launch {
|
||||
state = state.copy(saving = true)
|
||||
state =
|
||||
try {
|
||||
val created = withContext(Dispatchers.IO) { core.createNote(draft(cleanContent)) }
|
||||
// Prepend rather than reload: the new note belongs at the top
|
||||
// of the board, and a full re-query would cost a round trip to
|
||||
// tell us what we already know. Skipped when the board is not
|
||||
// showing plain notes — a note created while looking at Trash
|
||||
// does not belong in that list.
|
||||
val notes =
|
||||
if (state.destination == Destination.Notes && !state.searching) {
|
||||
listOf(created) + state.notes
|
||||
} else {
|
||||
state.notes
|
||||
}
|
||||
// A capture sheet can carry a reminder in its text one day;
|
||||
// more to the point, this is a store write and the rule here is
|
||||
// that every store write re-derives the alarm rather than each
|
||||
// call site deciding whether its particular write could matter.
|
||||
withContext(Dispatchers.IO) { onRemindersChanged() }
|
||||
state.copy(notes = notes, saving = false, error = null)
|
||||
} catch (e: Exception) {
|
||||
state.copy(saving = false, error = e.message ?: FALLBACK_ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────── the editor ──────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -238,12 +210,119 @@ class BoardViewModel(
|
||||
fun openNoteById(id: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching { withContext(Dispatchers.IO) { core.getNote(id) } }
|
||||
.onSuccess { state = state.copy(editing = it) }
|
||||
.onSuccess { state = state.copy(editing = it, editingSession = state.editingSession + 1) }
|
||||
}
|
||||
}
|
||||
|
||||
fun openNote(note: Note) {
|
||||
state = state.copy(editing = note)
|
||||
state = state.copy(editing = note, editingSession = state.editingSession + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the editor on a note that does not exist yet.
|
||||
*
|
||||
* The + button used to raise a separate capture sheet, which meant a note being
|
||||
* WRITTEN could not be given a colour, a reminder or a checklist — those live on
|
||||
* the editor's toolbar, and the sheet had none. Writing and editing are now the
|
||||
* same surface.
|
||||
*
|
||||
* The draft is a real [Note] carrying [DRAFT_ID] rather than a null, so the
|
||||
* editor renders it without knowing that "not saved yet" is a state it can be
|
||||
* in. It becomes a row on its first save; see [onDraftAction].
|
||||
*/
|
||||
fun compose() {
|
||||
draftDismissed = false
|
||||
state = state.copy(editing = blankDraft(), editingSession = state.editingSession + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set when a draft's editor closes, so a create still in flight does not reopen
|
||||
* it. The editor flushes its text and then closes, and the flush is a coroutine —
|
||||
* without this the note would be created, the screen would close, and the create
|
||||
* would finish and put the screen back.
|
||||
*/
|
||||
private var draftDismissed = false
|
||||
|
||||
/**
|
||||
* The editor's actions, for a note that has no row yet.
|
||||
*
|
||||
* Everything a toolbar button does needs an id to act on, so the first action
|
||||
* that needs one creates the note and replays itself against the real thing.
|
||||
*/
|
||||
private fun onDraftAction(
|
||||
draft: Note,
|
||||
action: EditorAction,
|
||||
) {
|
||||
when (action) {
|
||||
// Nothing exists, so leaving leaves nothing behind — which is what makes
|
||||
// tapping + and changing your mind free. Text typed before this point has
|
||||
// already gone to createFromDraft via the editor's autosave or its flush.
|
||||
EditorAction.Close, EditorAction.Trash -> {
|
||||
draftDismissed = true
|
||||
state = state.copy(editing = null)
|
||||
}
|
||||
EditorAction.DismissError -> dismissError()
|
||||
is EditorAction.SaveText -> createFromDraft(action.body)
|
||||
// Starting a checklist is the one toolbar action that means something on
|
||||
// a note with no text: a note whose whole content is its items is a note
|
||||
// this app already has (it is named from its first item). So it may
|
||||
// create an empty one — anything else needs words first.
|
||||
EditorAction.AddChecklist ->
|
||||
createFromDraft(draft.body, allowEmpty = true) { created ->
|
||||
onEditorAction(created, action)
|
||||
}
|
||||
// Colour, reminder, pin, labels: attributes OF a note, so there has to be
|
||||
// a note. With autosave at a second, "typed something" is true by the time
|
||||
// anyone reaches the toolbar; before that there is nothing to attribute.
|
||||
else -> createFromDraft(draft.body) { created -> onEditorAction(created, action) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a draft into a row, and keep the editor on it.
|
||||
*
|
||||
* Adopting the created note is what lets a session of autosaves stay one note:
|
||||
* the second save sees a real id and updates rather than creating again.
|
||||
*/
|
||||
private fun createFromDraft(
|
||||
content: String,
|
||||
allowEmpty: Boolean = false,
|
||||
then: (Note) -> Unit = {},
|
||||
) {
|
||||
val cleanContent = content.trim()
|
||||
// A blank draft is not a note. Ignored rather than rejected: tapping + and
|
||||
// walking away is a slip, not a mistake worth interrupting someone over.
|
||||
if (cleanContent.isEmpty() && !allowEmpty) return
|
||||
viewModelScope.launch {
|
||||
state = state.copy(saving = true)
|
||||
state =
|
||||
try {
|
||||
val created = withContext(Dispatchers.IO) { core.createNote(draft(cleanContent)) }
|
||||
// Prepend rather than reload: the new note belongs at the top of
|
||||
// the board, and a full re-query would cost a round trip to tell
|
||||
// us what we already know. Skipped when the board is not showing
|
||||
// plain notes — a note created while looking at Trash does not
|
||||
// belong in that list.
|
||||
val notes =
|
||||
if (state.destination == Destination.Notes && !state.searching) {
|
||||
listOf(created) + state.notes
|
||||
} else {
|
||||
state.notes
|
||||
}
|
||||
withContext(Dispatchers.IO) { onRemindersChanged() }
|
||||
// editingSession is NOT bumped: this is the same sitting, and the
|
||||
// editor's field must not be re-keyed underneath the typing.
|
||||
state.copy(
|
||||
notes = notes,
|
||||
editing = if (draftDismissed) state.editing else created,
|
||||
saving = false,
|
||||
error = null,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
state.copy(saving = false, error = e.message ?: FALLBACK_ERROR)
|
||||
}
|
||||
if (!draftDismissed) state.editing?.let(then)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -268,6 +347,10 @@ class BoardViewModel(
|
||||
note: Note,
|
||||
action: EditorAction,
|
||||
) {
|
||||
if (note.id == DRAFT_ID) {
|
||||
onDraftAction(note, action)
|
||||
return
|
||||
}
|
||||
val id = note.id
|
||||
when (action) {
|
||||
EditorAction.Close -> state = state.copy(editing = null)
|
||||
@@ -447,3 +530,33 @@ private fun draft(content: String): NoteDraft =
|
||||
// findable without anyone being asked to name it. A checklist is added afterwards,
|
||||
// in the editor — it is something a note HAS, not a different thing to capture.
|
||||
NoteDraft(body = content, color = DEFAULT_COLOR, items = null)
|
||||
|
||||
/**
|
||||
* The id a note has before it has been saved.
|
||||
*
|
||||
* A real id is a uuid, so the empty string cannot collide with one. Using a sentinel
|
||||
* rather than making the editor's note nullable keeps "not saved yet" out of a screen
|
||||
* that reads eight fields off the note and should not have to null-check any of them.
|
||||
*/
|
||||
internal const val DRAFT_ID = ""
|
||||
|
||||
private fun blankDraft(): Note =
|
||||
Note(
|
||||
id = DRAFT_ID,
|
||||
displayTitle = "",
|
||||
body = "",
|
||||
color = DEFAULT_COLOR,
|
||||
position = 0,
|
||||
pinned = false,
|
||||
archived = false,
|
||||
trashed = false,
|
||||
deletedAt = null,
|
||||
remindAt = null,
|
||||
recurrence = null,
|
||||
labels = emptyList(),
|
||||
items = emptyList(),
|
||||
attachments = emptyList(),
|
||||
previews = emptyList(),
|
||||
createdAt = null,
|
||||
updatedAt = null,
|
||||
)
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.R
|
||||
|
||||
/**
|
||||
* The new-note surface, opened by the + button.
|
||||
*
|
||||
* A bottom sheet rather than a full screen: capture should feel like a quick aside
|
||||
* from the board, not a place you navigate to and have to come back from. The
|
||||
* board stays visible behind it, so the note lands somewhere you can already see.
|
||||
*
|
||||
* It asks note-or-list up front rather than making that a mode you discover later,
|
||||
* because on a phone the two are genuinely different typing tasks and switching
|
||||
* halfway is worse than choosing at the start.
|
||||
*
|
||||
* ## Leaving keeps what you wrote
|
||||
*
|
||||
* Every way out of this sheet except Discard SAVES: the save button, tapping the
|
||||
* board behind it, swiping down, back, and the app being backgrounded. A sheet
|
||||
* that throws away a typed thought because you touched outside it is a sheet that
|
||||
* teaches people not to trust the app with a thought — and capture is the one
|
||||
* place this product cannot afford that.
|
||||
*
|
||||
* The same shape the editor settled on, for the same reason, with one difference:
|
||||
* capture also has to be abandonable, because tapping + and changing your mind is
|
||||
* a normal thing to do. That is what Discard is, and it is the only path that
|
||||
* loses anything. An empty draft needs neither — it is simply dropped, since a
|
||||
* blank note nobody asked for is worse than no note at all.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ComposeSheet(
|
||||
saving: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (String) -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
// Saveable, not just remembered: a rotation mid-sentence is the same lost
|
||||
// thought as a discarded one, and it was losing it before this.
|
||||
var content by rememberSaveable { mutableStateOf("") }
|
||||
val contentFocus = remember { FocusRequester() }
|
||||
|
||||
val written = content.isNotBlank()
|
||||
val leave = { if (written) onSave(content) else onDismiss() }
|
||||
|
||||
// Straight into the one field there is. A capture is a thought, and every field
|
||||
// someone has to tab past is the difference between "under a second" and not —
|
||||
// which is why the title field is gone rather than merely skipped (M13 step 3).
|
||||
LaunchedEffect(Unit) { contentFocus.requestFocus() }
|
||||
|
||||
// Backgrounding PERSISTS but does not close an empty sheet. Someone who tapped
|
||||
// + and then got distracted should find the composer where they left it; the
|
||||
// only reason to act here is that there is something to lose.
|
||||
FlushOnStop { if (written) onSave(content) }
|
||||
|
||||
ModalBottomSheet(onDismissRequest = leave, sheetState = sheetState) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.imePadding()
|
||||
.navigationBarsPadding(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// No note/list switch any more: there is one thing to capture. A
|
||||
// checklist is added to a note in the editor, once there is a note.
|
||||
PlainTextField(
|
||||
value = content,
|
||||
onValueChange = { content = it },
|
||||
modifier = Modifier.focusRequester(contentFocus),
|
||||
hint = R.string.compose_body_hint,
|
||||
minLines = MIN_CONTENT_LINES,
|
||||
)
|
||||
|
||||
SheetActions(
|
||||
canSave = !saving && written,
|
||||
onDiscard = onDismiss,
|
||||
onSave = { onSave(content) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SheetActions(
|
||||
canSave: Boolean,
|
||||
onDiscard: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
// "Discard", not "Cancel". Cancel means "undo what I am doing", which is
|
||||
// precisely what leaving no longer does — the word would now describe the
|
||||
// one button it is NOT attached to.
|
||||
TextButton(onClick = onDiscard) { Text(stringResource(R.string.compose_discard)) }
|
||||
Button(onClick = onSave, enabled = canSave) {
|
||||
Text(stringResource(R.string.compose_save))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val MIN_CONTENT_LINES = 4
|
||||
@@ -27,6 +27,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
@@ -38,6 +39,7 @@ import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.thoughtsync.R
|
||||
import com.fabledsword.thoughtsync.core.Label
|
||||
import com.fabledsword.thoughtsync.core.Note
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* The note editor: a full screen, not a sheet.
|
||||
@@ -55,6 +57,7 @@ import com.fabledsword.thoughtsync.core.Note
|
||||
@Composable
|
||||
fun NoteEditorScreen(
|
||||
note: Note,
|
||||
sessionKey: Long,
|
||||
labels: List<Label>,
|
||||
saving: Boolean,
|
||||
error: String?,
|
||||
@@ -63,19 +66,26 @@ fun NoteEditorScreen(
|
||||
val dark = isSystemInDarkTheme()
|
||||
val tint = noteTint(note.color)
|
||||
|
||||
// Keyed by note id: the editor is reused across notes, and without the key the
|
||||
// second note opened would show the first one's text.
|
||||
// Keyed by the SESSION, not by note.id: the editor is reused across notes, so it
|
||||
// needs a key — but a draft's id changes the moment it is first saved, and
|
||||
// re-keying on that would reset this field to whatever the store just returned,
|
||||
// throwing away every character typed during the write.
|
||||
//
|
||||
// Saveable, because a new note has nothing to fall back on. Rotating the phone
|
||||
// mid-capture used to be survivable only in the old capture sheet, which used
|
||||
// rememberSaveable for exactly this reason; the editor inherits the requirement
|
||||
// along with the job.
|
||||
//
|
||||
// TextFieldValue rather than String so the CARET can start at the end of the
|
||||
// text. A String field always begins its selection at offset zero, which would
|
||||
// drop the cursor before the first character — the wrong place for "carry on
|
||||
// writing this note", which is what opening an existing one usually means.
|
||||
var body by
|
||||
remember(note.id) {
|
||||
rememberSaveable(sessionKey, stateSaver = TextFieldValue.Saver) {
|
||||
mutableStateOf(TextFieldValue(note.body, TextRange(note.body.length)))
|
||||
}
|
||||
var picker by remember(note.id) { mutableStateOf(Picker.NONE) }
|
||||
var confirmingDelete by remember(note.id) { mutableStateOf(false) }
|
||||
var picker by remember(sessionKey) { mutableStateOf(Picker.NONE) }
|
||||
var confirmingDelete by remember(sessionKey) { mutableStateOf(false) }
|
||||
|
||||
// A note in the trash is a record, not a document: editing one would silently
|
||||
// resurrect work that was meant to be thrown away. It renders read-only, with
|
||||
@@ -104,10 +114,27 @@ fun NoteEditorScreen(
|
||||
// you cannot edit is noise. `note.id` as the key so the request fires again
|
||||
// when the reused editor is pointed at a different note.
|
||||
val bodyFocus = remember { FocusRequester() }
|
||||
LaunchedEffect(note.id) {
|
||||
LaunchedEffect(sessionKey) {
|
||||
if (!readOnly) bodyFocus.requestFocus()
|
||||
}
|
||||
|
||||
// Idle-debounced autosave. LaunchedEffect cancels and restarts on every
|
||||
// keystroke, so the delay only ever elapses once typing stops.
|
||||
//
|
||||
// Saving this often is affordable because a body write no longer costs a
|
||||
// revision: history snapshots once per editing session rather than once per
|
||||
// save. Before that, writing was expensive enough that this editor hoarded
|
||||
// text until it closed — and an app kill mid-session lost the lot.
|
||||
//
|
||||
// For a note that does not exist yet this is also what CREATES it, which is why
|
||||
// every toolbar button works moments after the first keystroke rather than
|
||||
// needing the note to be saved by hand first.
|
||||
LaunchedEffect(body.text, sessionKey) {
|
||||
if (readOnly || body.text == note.body) return@LaunchedEffect
|
||||
delay(AUTOSAVE_IDLE_MS)
|
||||
onAction(EditorAction.SaveText(body.text))
|
||||
}
|
||||
|
||||
BackHandler(onBack = leave)
|
||||
|
||||
// Leaving the APP is not closing the editor, so the text has to be saved
|
||||
@@ -303,4 +330,13 @@ private fun EditorField(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* How long typing has to stop before the note is written.
|
||||
*
|
||||
* Long enough that a normal sentence is one write, short enough that nothing
|
||||
* meaningful is at risk if the app dies. The flush on close and [FlushOnStop] still
|
||||
* cover the window between the last keystroke and this elapsing.
|
||||
*/
|
||||
private const val AUTOSAVE_IDLE_MS = 1_000L
|
||||
|
||||
private const val MIN_BODY_LINES = 6
|
||||
|
||||
@@ -10,9 +10,6 @@
|
||||
|
||||
<!-- Compose sheet -->
|
||||
<string name="compose_open">New note</string>
|
||||
<string name="compose_body_hint">Take a note…</string>
|
||||
<string name="compose_discard">Discard</string>
|
||||
<string name="compose_save">Save</string>
|
||||
|
||||
<!-- Board -->
|
||||
<string name="board_empty_note">Empty note</string>
|
||||
@@ -38,7 +35,7 @@
|
||||
<string name="board_open_note">Open note</string>
|
||||
<string name="editor_back">Back to notes</string>
|
||||
<string name="editor_add_checklist">Add a checklist</string>
|
||||
<string name="editor_body_hint">Note</string>
|
||||
<string name="editor_body_hint">Take a note…</string>
|
||||
<string name="editor_add_item">Add item</string>
|
||||
<string name="editor_remove_item">Remove item</string>
|
||||
<string name="editor_remove_label">Remove label</string>
|
||||
|
||||
Reference in New Issue
Block a user