Checklists in the body, colour from tags, and commit-derived CalVer #4

Merged
bvandeusen merged 73 commits from dev into main 2026-08-29 13:39:45 -04:00
5 changed files with 285 additions and 152 deletions
Showing only changes of commit 9ea2a2f9b6 - Show all commits
@@ -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>