10 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5.5 027ad6f672 DRY pass #2, batch 7, F21: update.rs reads installer markers one way (#5372)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 12s
Android / Core and FFI clippy and tests (push) Successful in 55s
CI & Build / integration (push) Successful in 1m45s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m17s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m55s
Android / Kotlin + Rust (APK) (push) Successful in 8m19s
Android / Build the server image (push) Successful in 1s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 30m2s
Desktop (Tauri) / Update manifest (push) Skipped
update.rs read_marker(): read the installer's marker file, parse it, and
log one that doesn't parse. adopt_installer_channel and
adopt_installer_server each wrote that out; they already shared adopt().

normalize_server's doc now says why it stays apart from
compat::normalize_base_url: one tidies what a person types, the other
refuses anything odd in what a script wrote. The tauri.conf updater
endpoint stays: read_source already documents that it is never consulted,
and removing it is a config change CI would be the first to try.

rustfmt --check is clean in the CI image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:00:39 -04:00
bvandeusenandClaude Opus 5.5 17076d6a79 DRY pass #2, batch 7, F21: the desktop's main window, clock and launcher dir (#5372)
- lib.rs MAIN_WINDOW: the "main" window label that the reminder worker,
  the capture window and the shell each wrote, 5 sites in all.
- lib.rs now_ms(): the epoch-milliseconds clock that the reminder worker
  and autosync's cycle stamp each computed. LastCycle.at_ms becomes i64,
  the same number on the wire.
- integration.rs applications_dir(): the XDG launcher directory that the
  entry path and the desktop-database refresh each built.

rustfmt --check is clean in the CI image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:00:03 -04:00
bvandeusenandClaude Opus 5.5 00dc8840cc DRY pass #2, batch 6, F20: log tags, snooze lengths, one instant parser (#5372)
- LogTag.kt: the four tags the app logs under. Eight files each declared
  one of them as a string.
- SNOOZE_HOUR/SNOOZE_DAY sit beside EditorAction.SnoozeReminder; the
  notification's snooze uses SNOOZE_HOUR instead of its own 60.
- Reminders.at reads through ui.epochMillis, the parse the card and the
  overdue check already use.

Checked with ktlint and detekt in the CI image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:58:41 -04:00
bvandeusenandClaude Opus 5.5 f46dd7a707 DRY pass #2, batch 6, F19: Banner, the tinted strip (#5372)
ErrorBanner.kt Banner(tintKey) { … } is the rounded, palette-tinted row
that ErrorBanner (red) and UpdateBanner (blue) each built. TintChip moves
to Chips.kt with CHIP_RADIUS, which keeps NoteCard.kt under detekt's
function count. Checked with ktlint and detekt in the CI image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:57:25 -04:00
bvandeusenandClaude Opus 5.5 7ee4fed246 Android: loneUrl trims without a spread
detekt's SpreadOperator flagged trimEnd(*TRAILING_PUNCTUATION) from
1c4bf56. trimEnd { it in TRAILING_PUNCTUATION } trims the same characters
without copying the array. Checked with detekt in the CI image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:57:25 -04:00
bvandeusenandClaude Opus 5.5 9b8c9474bc DRY pass #2, batch 6, F19: TitleAndBody, TintChip and Swatch (#5372)
- BoardScreen TitleAndBody: the title plus quieter body that the empty
  board and the store-unavailable screen each wrote.
- NoteCard TintChip: the one-line palette chip that the reminder and
  shared-by chips each drew in full.
- TagsScreen Swatch: the colour dot that the tag row (tappable) and the
  colour picker each built.

Kept: the label chips. Each uses its tag's ink and a bigger shape, not
the card's chip pair. Checked with ktlint in the CI image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:56:18 -04:00
bvandeusenandClaude Opus 5.5 4a810df0a9 DRY pass #2, batch 6, F19: the note menu's shared rows (#5372)
TrashedNoteItems, PinItem and ArchiveItem (EditorChrome.kt) are the rows
that the editor's overflow and the board's long-press menu each built: a
trashed note's restore and delete-forever, and the pin and archive
toggles. Each menu keeps its own order and its owner-only rows. Checked
with ktlint in the CI image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:55:06 -04:00
bvandeusenandClaude Opus 5.5 0d82c17224 DRY pass #2, batch 6, F19: manualLabelIds and sharerName (#5372)
- Note.manualLabelIds (NoteAccess.kt): the tags attached by hand, which
  the label picker, the chip's remove button and the board's create-label
  each filtered out of note.labels.
- sharerName(note): who shared a note, or "Someone", which the shared-by
  line and the card's chip each spelled out.

Checked with ktlint in the CI image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:54:27 -04:00
bvandeusenandClaude Opus 5.5 709ccca64c DRY pass #2, batch 6, F19: Sheet, the bottom sheet with its title (#5372)
EditorPickers.kt Sheet(title, onDismiss, modifier, verticalArrangement):
a ModalBottomSheet holding a full-width column with the screen margin,
clear of the navigation bar, under SheetTitle. The filter, tag picker,
reminder and share sheets each built that. A site's extra (a scroll, ime
padding, bottom space) is now applied after the navigation-bar inset
rather than before it. The total inset is the same either way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:53:51 -04:00
bvandeusenandClaude Opus 5.5 f2fac1674b DRY pass #2, batch 6, F19: BackButton, the arrow out of a full-screen surface (#5372)
Panel.kt BackButton(onClick, label): the IconButton + ArrowBack that the
tags, sync and editor top bars each built. Each keeps its own spoken
label. Checked with ktlint in the CI image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:52:54 -04:00
31 changed files with 435 additions and 377 deletions
@@ -43,7 +43,7 @@ import java.io.File
* [UpdateReceiver], which is why a failure can be shown rather than guessed at.
*/
object AppUpdate {
private const val TAG = "InkwellUpdate"
private const val TAG = LogTag.UPDATE
/** This build's versionCode — what the server's is compared against. */
fun installedVersionCode(context: Context): Long =
@@ -53,6 +53,6 @@ class InkwellApplication : Application() {
}
private companion object {
const val TAG = "Inkwell"
const val TAG = LogTag.APP
}
}
@@ -53,7 +53,7 @@ class KeystoreTokenSeal : TokenSeal {
}
private companion object {
const val TAG = "Inkwell"
const val TAG = LogTag.APP
const val KEYSTORE = "AndroidKeyStore"
const val ALIAS = "inkwell-device-token"
const val KEY_BITS = 256
@@ -0,0 +1,12 @@
package com.fabledsword.inkwell
/**
* The tags the app logs under, one per concern, so `adb logcat -s` on one of them
* finds every line about it whichever file wrote it.
*/
internal object LogTag {
const val APP = "Inkwell"
const val UPDATE = "InkwellUpdate"
const val REMINDERS = "InkwellReminders"
const val WORKER = "InkwellWorker"
}
@@ -117,5 +117,5 @@ internal object ReminderNotification {
)
private const val CHANNEL = "reminders"
private const val TAG = "InkwellReminders"
private const val TAG = LogTag.REMINDERS
}
@@ -67,6 +67,6 @@ class ReminderReceiver : BroadcastReceiver() {
const val ACTION_DUE = "com.fabledsword.inkwell.REMINDER_DUE"
const val ACTION_DONE = "com.fabledsword.inkwell.REMINDER_DONE"
const val ACTION_SNOOZE = "com.fabledsword.inkwell.REMINDER_SNOOZE"
private const val TAG = "InkwellReminders"
private const val TAG = LogTag.REMINDERS
}
}
@@ -9,7 +9,8 @@ import android.util.Log
import androidx.core.app.NotificationManagerCompat
import com.fabledsword.inkwell.core.Inkwell
import com.fabledsword.inkwell.core.Note
import java.time.OffsetDateTime
import com.fabledsword.inkwell.ui.SNOOZE_HOUR
import com.fabledsword.inkwell.ui.epochMillis
/**
* Getting a reminder in front of someone at the time they asked for.
@@ -51,8 +52,7 @@ object Reminders {
*/
private const val MISSED_WINDOW_MS = 24L * 60 * 60 * 1000
private const val SNOOZE_MINUTES = 60L
private const val TAG = "InkwellReminders"
private const val TAG = LogTag.REMINDERS
/**
* Announce what is due, then schedule the next one.
@@ -137,7 +137,7 @@ object Reminders {
core: Inkwell,
noteId: String,
) {
runCatching { core.snoozeReminder(noteId, SNOOZE_MINUTES) }
runCatching { core.snoozeReminder(noteId, SNOOZE_HOUR) }
.onFailure { Log.w(TAG, "could not snooze reminder", it) }
ReminderNotification.dismiss(context, noteId)
}
@@ -194,10 +194,7 @@ object Reminders {
// ────────────────────────────── bookkeeping ──────────────────────────────
/** Epoch millis of a note's reminder, or null if it has none we can read. */
private fun at(note: Note): Long? =
note.remindAt?.let {
runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull()
}
private fun at(note: Note): Long? = note.remindAt?.let(::epochMillis)
/**
* Identity of one OCCURRENCE, not of the note.
@@ -67,6 +67,6 @@ class SyncWorker(
}
private companion object {
const val TAG = "InkwellWorker"
const val TAG = LogTag.WORKER
}
}
@@ -72,6 +72,6 @@ class UpdateReceiver : BroadcastReceiver() {
companion object {
const val ACTION_INSTALLED = "com.fabledsword.inkwell.UPDATE_INSTALLED"
private const val TAG = "InkwellUpdate"
private const val TAG = LogTag.UPDATE
}
}
@@ -1,19 +1,16 @@
package com.fabledsword.inkwell.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -115,53 +112,46 @@ internal fun FilterSheet(
onChange: (BoardFilters) -> Unit,
onDismiss: () -> Unit,
) {
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.navigationBarsPadding()
.verticalScroll(rememberScrollState()),
) {
SheetTitle(R.string.filters)
Sheet(
title = R.string.filters,
onDismiss = onDismiss,
modifier = Modifier.verticalScroll(rememberScrollState()),
) {
FilterHeading(R.string.filters_show)
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
FilterChip(
selected = filters.hasAttachment,
onClick = { onChange(filters.copy(hasAttachment = !filters.hasAttachment)) },
label = { Text(stringResource(R.string.filters_has_attachment)) },
)
FilterChip(
selected = filters.sharedWithMe,
onClick = { onChange(filters.copy(sharedWithMe = !filters.sharedWithMe)) },
label = { Text(stringResource(R.string.filters_shared_with_me)) },
)
}
FilterHeading(R.string.filters_show)
// No heading and no empty row when there are no tags yet: a section that
// can only ever say "none" is noise in a sheet this small.
if (labels.isNotEmpty()) {
FilterHeading(R.string.filters_tags)
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
FilterChip(
selected = filters.hasAttachment,
onClick = { onChange(filters.copy(hasAttachment = !filters.hasAttachment)) },
label = { Text(stringResource(R.string.filters_has_attachment)) },
)
FilterChip(
selected = filters.sharedWithMe,
onClick = { onChange(filters.copy(sharedWithMe = !filters.sharedWithMe)) },
label = { Text(stringResource(R.string.filters_shared_with_me)) },
)
}
// No heading and no empty row when there are no tags yet: a section that
// can only ever say "none" is noise in a sheet this small.
if (labels.isNotEmpty()) {
FilterHeading(R.string.filters_tags)
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
labels.forEach { label ->
FilterChip(
selected = label.id in filters.labelIds,
onClick = { onChange(filters.toggleLabel(label.id)) },
label = { Text(label.name) },
)
}
labels.forEach { label ->
FilterChip(
selected = label.id in filters.labelIds,
onClick = { onChange(filters.toggleLabel(label.id)) },
label = { Text(label.name) },
)
}
}
}
if (filters.count > 0) {
TextButton(
onClick = { onChange(BoardFilters()) },
modifier = Modifier.align(Alignment.End),
) {
Text(stringResource(R.string.filters_clear))
}
if (filters.count > 0) {
TextButton(
onClick = { onChange(BoardFilters()) },
modifier = Modifier.align(Alignment.End),
) {
Text(stringResource(R.string.filters_clear))
}
}
}
@@ -641,13 +641,7 @@ private fun EmptyBoard(state: BoardState) {
) {
item {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
Text(
text = body,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TitleAndBody(title, body)
}
}
}
@@ -677,19 +671,28 @@ fun StoreUnavailableScreen(reason: String?) {
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = stringResource(R.string.store_unavailable_title),
style = MaterialTheme.typography.titleMedium,
)
Spacer(Modifier.height(8.dp))
Text(
text = reason ?: stringResource(R.string.store_unavailable_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
TitleAndBody(
title = stringResource(R.string.store_unavailable_title),
body = reason ?: stringResource(R.string.store_unavailable_body),
)
}
}
/** A whole-screen message: what is the case, then what it means, in the quieter ink. */
@Composable
private fun TitleAndBody(
title: String,
body: String,
) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
Text(
text = body,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Not private: the reminder notice is board content and has to line up with the
// search bar and the cards, so it shares the board's gutter rather than guessing.
internal val GUTTER = 12.dp
@@ -505,8 +505,7 @@ class BoardViewModel(
action.name.trim().takeIf { it.isNotEmpty() }?.let { name ->
mutate {
val label = it.createLabel(name)
val manual = note.labels.filterNot { l -> l.viaTag }.map { l -> l.id }
it.setNoteLabels(id, (manual + label.id).distinct())
it.setNoteLabels(id, (note.manualLabelIds + label.id).distinct())
}
// The drawer lists labels with their note counts, and both
// just changed.
@@ -0,0 +1,38 @@
package com.fabledsword.inkwell.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/** The corner of every chip on a card: tags, reminders, who shared it. */
internal val CHIP_RADIUS = 6.dp
/** A one-line chip on a card, in the chip colours of the palette entry [tintKey]. */
@Composable
internal fun TintChip(
text: String,
tintKey: String,
) {
val dark = isSystemInDarkTheme()
val tint = noteTint(tintKey)
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = tint.chipForeground(dark),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.clip(RoundedCornerShape(CHIP_RADIUS))
.background(tint.chipBackground(dark))
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
@@ -114,3 +114,7 @@ sealed interface EditorAction {
val previewId: String,
) : EditorAction
}
/** The two snoozes offered: the editor's buttons, and an hour from the notification. */
internal const val SNOOZE_HOUR = 60L
internal const val SNOOZE_DAY = 1440L
@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
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.Check
import androidx.compose.material.icons.filled.Close
@@ -96,12 +95,7 @@ fun EditorTopBar(
// 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.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.editor_back),
)
}
BackButton(onClose, R.string.editor_back)
},
actions = {
if (!readOnly && owner) {
@@ -293,13 +287,9 @@ private fun OverflowMenu(
}
DropdownMenu(expanded = open, onDismissRequest = close) {
if (note.trashed) {
MenuItem(R.string.editor_restore, close) { onAction(EditorAction.Restore) }
MenuItem(R.string.editor_delete_forever, close, onConfirmDelete)
TrashedNoteItems(close, onAction, onConfirmDelete)
} else {
MenuItem(
if (note.pinned) R.string.editor_unpin else R.string.editor_pin,
close,
) { onAction(EditorAction.SetPinned(!note.pinned)) }
PinItem(note, close, onAction)
if (owner) {
MenuItem(R.string.editor_labels, close) { onPicker(Picker.LABELS) }
}
@@ -307,10 +297,7 @@ private fun OverflowMenu(
if (owner && note.id != DRAFT_ID) {
MenuItem(R.string.editor_share, close, onShare)
}
MenuItem(
if (note.archived) R.string.editor_unarchive else R.string.editor_archive,
close,
) { onAction(EditorAction.SetArchived(!note.archived)) }
ArchiveItem(note, close, onAction)
if (owner) {
MenuItem(R.string.editor_trash, close) { onAction(EditorAction.Trash) }
}
@@ -319,6 +306,44 @@ private fun OverflowMenu(
}
}
// The rows a note's menu shares between the editor's overflow and the board's
// long-press menu: two ways to the same acts, worded the same way.
/** A trashed note's two ways out: back, or gone for good (which asks first). */
@Composable
internal fun TrashedNoteItems(
close: () -> Unit,
onAction: (EditorAction) -> Unit,
onConfirmDelete: () -> Unit,
) {
MenuItem(R.string.editor_restore, close) { onAction(EditorAction.Restore) }
MenuItem(R.string.editor_delete_forever, close, onConfirmDelete)
}
@Composable
internal fun PinItem(
note: Note,
close: () -> Unit,
onAction: (EditorAction) -> Unit,
) {
MenuItem(
if (note.pinned) R.string.editor_unpin else R.string.editor_pin,
close,
) { onAction(EditorAction.SetPinned(!note.pinned)) }
}
@Composable
internal fun ArchiveItem(
note: Note,
close: () -> Unit,
onAction: (EditorAction) -> Unit,
) {
MenuItem(
if (note.archived) R.string.editor_unarchive else R.string.editor_archive,
close,
) { onAction(EditorAction.SetArchived(!note.archived)) }
}
/**
* The note's labels, each removable.
*
@@ -367,10 +392,7 @@ fun EditorLabelRow(
// Only the MANUAL labels are sent: the core replaces
// exactly those, and including a tag label here would ask
// it to own something the body text already owns.
val kept =
note.labels
.filterNot { it.viaTag || it.id == label.id }
.map { it.id }
val kept = note.manualLabelIds - label.id
onAction(EditorAction.SetLabels(kept))
}) {
Icon(
@@ -425,6 +447,3 @@ fun EditorReminderRow(
}
}
}
private const val SNOOZE_HOUR = 60L
private const val SNOOZE_DAY = 1440L
@@ -1,8 +1,10 @@
package com.fabledsword.inkwell.ui
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
@@ -74,80 +76,69 @@ fun LabelSheet(
onDismiss: () -> Unit,
) {
var typed by remember { mutableStateOf("") }
val manual =
note.labels
.filterNot { it.viaTag }
.map { it.id }
.toSet()
val manual = note.manualLabelIds.toSet()
val viaTag =
note.labels
.filter { it.viaTag }
.map { it.id }
.toSet()
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.imePadding()
.navigationBarsPadding(),
) {
SheetTitle(R.string.label_picker_title)
Sheet(
title = R.string.label_picker_title,
onDismiss = onDismiss,
modifier = Modifier.imePadding(),
) {
PlainTextField(
value = typed,
onValueChange = { typed = it },
hint = R.string.label_new_hint,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(onDone = {
onAction(EditorAction.CreateLabel(typed))
typed = ""
}),
)
PlainTextField(
value = typed,
onValueChange = { typed = it },
hint = R.string.label_new_hint,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(onDone = {
onAction(EditorAction.CreateLabel(typed))
typed = ""
}),
)
// Capped rather than unbounded: a sheet that grows past the screen
// makes its own scroll fight the sheet's drag gesture.
LazyColumn(modifier = Modifier.heightIn(max = LABEL_LIST_MAX_HEIGHT)) {
items(items = labels, key = { it.id }) { label ->
val fromTag = label.id in viaTag
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = fromTag || label.id in manual,
enabled = !fromTag,
onCheckedChange = { on ->
val next = if (on) manual + label.id else manual - label.id
onAction(EditorAction.SetLabels(next.toList()))
},
)
// Capped rather than unbounded: a sheet that grows past the screen
// makes its own scroll fight the sheet's drag gesture.
LazyColumn(modifier = Modifier.heightIn(max = LABEL_LIST_MAX_HEIGHT)) {
items(items = labels, key = { it.id }) { label ->
val fromTag = label.id in viaTag
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = fromTag || label.id in manual,
enabled = !fromTag,
onCheckedChange = { on ->
val next = if (on) manual + label.id else manual - label.id
onAction(EditorAction.SetLabels(next.toList()))
},
)
Text(
text = label.name,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 4.dp),
)
if (fromTag) {
Text(
text = label.name,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 4.dp),
text = stringResource(R.string.label_from_tag),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp),
)
if (fromTag) {
Text(
text = stringResource(R.string.label_from_tag),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp),
)
}
}
}
}
}
if (labels.isEmpty()) {
Text(
text = stringResource(R.string.label_none_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 16.dp),
)
}
if (labels.isEmpty()) {
Text(
text = stringResource(R.string.label_none_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 16.dp),
)
}
}
}
@@ -170,60 +161,53 @@ fun ReminderSheet(
) {
var exact by remember { mutableStateOf(false) }
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.navigationBarsPadding(),
) {
SheetTitle(R.string.reminder_title)
reminderPresets().forEach { (labelRes, at) ->
Text(
text = "${stringResource(labelRes)} · ${formatInstant(rfc3339(at))}",
style = MaterialTheme.typography.bodyLarge,
modifier =
Modifier
.fillMaxWidth()
.clickable {
onAction(EditorAction.SetReminder(rfc3339(at)))
onDismiss()
}.padding(vertical = 12.dp),
)
}
Sheet(
title = R.string.reminder_title,
onDismiss = onDismiss,
) {
reminderPresets().forEach { (labelRes, at) ->
Text(
text = stringResource(R.string.reminder_pick),
text = "${stringResource(labelRes)} · ${formatInstant(rfc3339(at))}",
style = MaterialTheme.typography.bodyLarge,
modifier =
Modifier
.fillMaxWidth()
.clickable { exact = true }
.padding(vertical = 12.dp),
.clickable {
onAction(EditorAction.SetReminder(rfc3339(at)))
onDismiss()
}.padding(vertical = 12.dp),
)
}
// Repeat only appears once there IS a reminder — a recurrence rule on
// a note with no time to recur from is a setting that does nothing.
if (note.remindAt != null) {
RecurrenceChips(
current = note.recurrence,
onPick = { onAction(EditorAction.SetRecurrence(it)) },
)
Text(
text = stringResource(R.string.reminder_clear),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error,
modifier =
Modifier
.fillMaxWidth()
.clickable {
onAction(EditorAction.ClearReminder)
onDismiss()
}.padding(vertical = 12.dp),
)
}
Text(
text = stringResource(R.string.reminder_pick),
style = MaterialTheme.typography.bodyLarge,
modifier =
Modifier
.fillMaxWidth()
.clickable { exact = true }
.padding(vertical = 12.dp),
)
// Repeat only appears once there IS a reminder — a recurrence rule on
// a note with no time to recur from is a setting that does nothing.
if (note.remindAt != null) {
RecurrenceChips(
current = note.recurrence,
onPick = { onAction(EditorAction.SetRecurrence(it)) },
)
Text(
text = stringResource(R.string.reminder_clear),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error,
modifier =
Modifier
.fillMaxWidth()
.clickable {
onAction(EditorAction.ClearReminder)
onDismiss()
}.padding(vertical = 12.dp),
)
}
}
@@ -337,6 +321,36 @@ private fun RecurrenceChips(
}
}
/**
* A bottom sheet with its title: full width, the screen's side margin, and clear of
* the navigation bar. [modifier] adds to that, after the inset (ime padding, a
* scroll, extra space at the bottom).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun Sheet(
@StringRes title: Int,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
content: @Composable ColumnScope.() -> Unit,
) {
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.navigationBarsPadding()
.then(modifier),
verticalArrangement = verticalArrangement,
) {
SheetTitle(title)
content()
}
}
}
@Composable
internal fun SheetTitle(labelRes: Int) {
Text(
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -28,9 +29,28 @@ import com.fabledsword.inkwell.R
fun ErrorBanner(
message: String,
onDismiss: () -> Unit,
) {
Banner("red") {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onDismiss) { Text(stringResource(R.string.error_dismiss)) }
}
}
/**
* A strip across the top of a screen, tinted from the note palette by [tintKey]: a
* message on the left and what can be done about it on the right.
*/
@Composable
fun Banner(
tintKey: String,
content: @Composable RowScope.() -> Unit,
) {
val dark = isSystemInDarkTheme()
val tint = noteTint("red")
val tint = noteTint(tintKey)
Row(
modifier =
Modifier
@@ -42,12 +62,7 @@ fun ErrorBanner(
.padding(start = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onDismiss) { Text(stringResource(R.string.error_dismiss)) }
content()
}
}
@@ -57,7 +57,7 @@ fun loneUrl(body: String): String? =
.matchEntire(body)
?.groupValues
?.get(1)
?.trimEnd(*TRAILING_PUNCTUATION)
?.trimEnd { it in TRAILING_PUNCTUATION }
?.ifEmpty { null }
/**
@@ -29,3 +29,11 @@ val Note.readOnlyHere: Boolean
/** The owner's own controls apply: tags, reminder, files, previews, share, trash. */
val Note.ownerControlsHere: Boolean
get() = !readOnlyHere && access == NoteAccess.OWNER
/**
* The tags attached by hand (the picker, or a lifted line), as opposed to the ones
* the body's own `#tags` carry. A label write sets exactly these; a body tag is the
* body's to keep.
*/
val Note.manualLabelIds: List<String>
get() = labels.filterNot { it.viaTag }.map { it.id }
@@ -224,17 +224,10 @@ private fun NoteMenu(
val owner = note.access == NoteAccess.OWNER
DropdownMenu(expanded = expanded && (owner || !note.trashed), onDismissRequest = onDismiss) {
if (note.trashed) {
MenuItem(R.string.editor_restore, onDismiss) { onAction(EditorAction.Restore) }
MenuItem(R.string.editor_delete_forever, onDismiss, onConfirmDelete)
TrashedNoteItems(onDismiss, onAction, onConfirmDelete)
} else {
MenuItem(
if (note.pinned) R.string.editor_unpin else R.string.editor_pin,
onDismiss,
) { onAction(EditorAction.SetPinned(!note.pinned)) }
MenuItem(
if (note.archived) R.string.editor_unarchive else R.string.editor_archive,
onDismiss,
) { onAction(EditorAction.SetArchived(!note.archived)) }
PinItem(note, onDismiss, onAction)
ArchiveItem(note, onDismiss, onAction)
if (owner) {
MenuItem(R.string.editor_trash, onDismiss) { onAction(EditorAction.Trash) }
}
@@ -431,20 +424,7 @@ private fun ReminderChip(
instant: String,
recurrence: String?,
) {
val dark = isSystemInDarkTheme()
val tint = noteTint(if (isPast(instant)) "red" else "default")
Text(
text = reminderLabel(instant, recurrence),
style = MaterialTheme.typography.labelSmall,
color = tint.chipForeground(dark),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.clip(RoundedCornerShape(CHIP_RADIUS))
.background(tint.chipBackground(dark))
.padding(horizontal = 6.dp, vertical = 2.dp),
)
TintChip(reminderLabel(instant, recurrence), if (isPast(instant)) "red" else "default")
}
/**
@@ -456,28 +436,12 @@ private fun SharedChip(note: Note) {
val text =
when {
note.access != NoteAccess.OWNER ->
stringResource(
R.string.share_chip_by,
note.sharedBy?.displayName?.takeIf { it.isNotBlank() } ?: stringResource(R.string.share_someone),
)
stringResource(R.string.share_chip_by, sharerName(note))
note.shared -> stringResource(R.string.share_chip_shared)
else -> return
}
val dark = isSystemInDarkTheme()
val tint = noteTint("default")
Spacer(Modifier.height(8.dp))
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = tint.chipForeground(dark),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.clip(RoundedCornerShape(CHIP_RADIUS))
.background(tint.chipBackground(dark))
.padding(horizontal = 6.dp, vertical = 2.dp),
)
TintChip(text, "default")
}
/** A box that does nothing when tapped: someone else's note, shared to view. */
@@ -553,4 +517,3 @@ private val CARD_SURFACE_DARK = Color(0xFF171717)
// surfaces stay the same card. Contrast: #B8B8B8 on white 1.98, #404040 on #171717 1.73.
private val CARD_EDGE_LIGHT = Color(0xFFB8B8B8)
private val CARD_EDGE_DARK = Color(0xFF404040)
private val CHIP_RADIUS = 6.dp
@@ -9,8 +9,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -175,6 +179,17 @@ fun ConfirmDialog(
)
}
/** The arrow out of a full-screen surface, at the start of its top bar. */
@Composable
fun BackButton(
onClick: () -> Unit,
@StringRes label: Int,
) {
IconButton(onClick = onClick) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(label))
}
}
/** Secondary text under a field or a heading: smaller, and in the quieter ink. */
@Composable
fun Hint(
@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -18,7 +17,6 @@ import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -39,11 +37,16 @@ import com.fabledsword.inkwell.core.NoteShare
import com.fabledsword.inkwell.core.ShareGroup
import com.fabledsword.inkwell.core.ShareTarget
/** Who shared [note] with us, by name, or "Someone" when the server sent none. */
@Composable
internal fun sharerName(note: Note): String =
note.sharedBy?.displayName?.takeIf { it.isNotBlank() } ?: stringResource(R.string.share_someone)
/** "Shared by Robin · view only" over someone else's note; nothing over our own. */
@Composable
fun SharedByLine(note: Note) {
if (note.access == NoteAccess.OWNER) return
val who = note.sharedBy?.displayName?.takeIf { it.isNotBlank() } ?: stringResource(R.string.share_someone)
val who = sharerName(note)
val line =
if (note.access == NoteAccess.EDIT) {
stringResource(R.string.share_by_can_edit, who)
@@ -75,23 +78,17 @@ fun ShareSheet(
onUnshare: (shareId: String) -> Unit,
onDismiss: () -> Unit,
) {
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 16.dp)
.navigationBarsPadding(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
SheetTitle(R.string.share_title)
when {
state.loading -> Muted(stringResource(R.string.share_loading))
state.needsServer -> Muted(stringResource(R.string.share_needs_server))
state.loadError != null -> ErrorText(state.loadError)
else -> ShareBody(state, onShare, onUnshare)
}
Sheet(
title = R.string.share_title,
onDismiss = onDismiss,
modifier = Modifier.padding(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
when {
state.loading -> Muted(stringResource(R.string.share_loading))
state.needsServer -> Muted(stringResource(R.string.share_needs_server))
state.loadError != null -> ErrorText(state.loadError)
else -> ShareBody(state, onShare, onUnshare)
}
}
}
@@ -9,13 +9,9 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
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
@@ -76,14 +72,7 @@ fun SyncScreen(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.sync_title)) },
navigationIcon = {
IconButton(onClick = onClose) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.editor_back),
)
}
},
navigationIcon = { BackButton(onClose, R.string.editor_back) },
)
},
) { padding ->
@@ -19,7 +19,6 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
@@ -104,14 +103,7 @@ fun TagsScreen(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.tags_title)) },
navigationIcon = {
IconButton(onClick = onClose) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.tags_back),
)
}
},
navigationIcon = { BackButton(onClose, R.string.tags_back) },
)
},
) { padding ->
@@ -243,6 +235,24 @@ fun TagsScreen(
*/
internal fun hashtag(name: String): String = "#$name"
/** A tag's colour as a dot: the row's at-a-glance colour, and each choice in the picker. */
@Composable
private fun Swatch(
tint: NoteTint,
dark: Boolean,
modifier: Modifier = Modifier,
) {
Box(
modifier =
Modifier
.size(SWATCH)
.clip(CircleShape)
.background(tint.chipBackground(dark))
.border(1.dp, tint.chipBorder(dark), CircleShape)
.then(modifier),
)
}
@Composable
private fun NewTagField(
enabled: Boolean,
@@ -321,15 +331,7 @@ private fun TagRow(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier =
Modifier
.size(SWATCH)
.clip(CircleShape)
.background(tint.chipBackground(dark))
.border(1.dp, tint.chipBorder(dark), CircleShape)
.clickable(enabled = enabled, onClick = onColour),
)
Swatch(tint, dark, Modifier.clickable(enabled = enabled, onClick = onColour))
Column(modifier = Modifier.weight(1f)) {
Text(
@@ -520,14 +522,7 @@ private fun ColourDialog(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier =
Modifier
.size(SWATCH)
.clip(CircleShape)
.background(tint.chipBackground(dark))
.border(1.dp, tint.chipBorder(dark), CircleShape),
)
Swatch(tint, dark)
Text(
text = tint.label,
style = MaterialTheme.typography.bodyMedium,
@@ -1,8 +1,5 @@
package com.fabledsword.inkwell.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -10,7 +7,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
@@ -23,9 +19,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -152,19 +146,7 @@ fun UpdateBanner(
onInstall: () -> Unit,
onDismiss: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val tint = noteTint("blue")
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp)
.clip(RoundedCornerShape(BANNER_RADIUS))
.background(tint.background(dark))
.border(1.dp, tint.border(dark), RoundedCornerShape(BANNER_RADIUS))
.padding(start = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Banner("blue") {
Text(
text = stringResource(R.string.update_banner_ready, version),
style = MaterialTheme.typography.bodyMedium,
@@ -180,7 +162,6 @@ fun UpdateBanner(
}
}
private val BANNER_RADIUS = 12.dp
private val BANNER_SPINNER = 18.dp
/**
+3 -5
View File
@@ -46,7 +46,7 @@
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
use std::sync::Mutex;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use std::time::{Duration, Instant};
use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager};
@@ -85,7 +85,7 @@ pub enum Trigger {
#[derive(Debug, Clone, Serialize)]
pub struct LastCycle {
/// When it finished, in milliseconds since the epoch — the frontend formats it.
pub at_ms: u64,
pub at_ms: i64,
pub trigger: Trigger,
pub ok: bool,
/// Why it failed, in words a person can act on. None when `ok`.
@@ -299,9 +299,7 @@ fn worker(app: AppHandle, rx: Receiver<Request>) {
}
let cycle = LastCycle {
at_ms: SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as u64),
at_ms: crate::now_ms(),
trigger,
ok: result.is_ok(),
error: result.as_ref().err().cloned(),
+1 -1
View File
@@ -214,7 +214,7 @@ pub fn capture_done(saved: bool, app: AppHandle) -> Result<(), String> {
window.hide().map_err(|e| e.to_string())?;
}
if saved {
if let Some(main) = app.get_webview_window("main") {
if let Some(main) = app.get_webview_window(crate::MAIN_WINDOW) {
// Failure here is cosmetic — the note is saved either way and the board
// will show it on its next load — so it is logged, not raised.
if let Err(e) = main.emit(CAPTURED_EVENT, ()) {
+7 -2
View File
@@ -42,8 +42,13 @@ fn appimage_path() -> Option<PathBuf> {
std::env::var_os("APPIMAGE").map(PathBuf::from)
}
/// Where per-user launchers live (XDG): `~/.local/share/applications`.
fn applications_dir(home: &Path) -> PathBuf {
home.join(".local/share/applications")
}
fn desktop_entry_path() -> Option<PathBuf> {
home().map(|h| h.join(".local/share/applications").join(DESKTOP_ENTRY_NAME))
home().map(|h| applications_dir(&h).join(DESKTOP_ENTRY_NAME))
}
fn icon_path() -> Option<PathBuf> {
@@ -154,7 +159,7 @@ fn set_executable(_p: &Path) -> Result<(), String> {
// Best-effort: refresh the desktop database so the entry appears promptly. The entry
// still works on next login without it, so failures are ignored.
fn refresh_desktop_database(home: &Path) {
let apps = home.join(".local/share/applications");
let apps = applications_dir(home);
let _ = std::process::Command::new("update-desktop-database")
.arg(&apps)
.output();
+14 -2
View File
@@ -54,6 +54,18 @@ pub(crate) fn display_version() -> &'static str {
// this crate — the extraction changed where they live, not what they are.
use inkwell_core::{local, sync};
/// The app's own window, as `tauri.conf.json` names it. The capture window is the
/// other one (`capture::CAPTURE_WINDOW`).
pub(crate) const MAIN_WINDOW: &str = "main";
/// Milliseconds since the Unix epoch: the unit the page's clocks and the reminder
/// worker count in. 0 for a clock set before 1970.
pub(crate) fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as i64)
}
pub fn run() {
use tauri_plugin_log::{Target, TargetKind};
@@ -156,7 +168,7 @@ pub fn run() {
reminders::start(app.handle())?;
// Coming back to the window is when someone is about to look, so it asks
// for a cycle. Rate-limited in autosync, because focus flaps constantly.
if let Some(window) = app.get_webview_window("main") {
if let Some(window) = app.get_webview_window(MAIN_WINDOW) {
let handle = app.handle().clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::Focused(true) = event {
@@ -243,7 +255,7 @@ pub fn run() {
/// colour, is a cosmetic loss and must never stop the app from opening.
fn paint_window_before_the_webview_does(app: &tauri::App) {
use tauri::Manager;
let Some(window) = app.get_webview_window("main") else {
let Some(window) = app.get_webview_window(MAIN_WINDOW) else {
return;
};
// Unknown theme reads as light, matching the platform default we'd get anyway.
+4 -10
View File
@@ -21,7 +21,7 @@
//! reports clicks only on mobile. The toast waiting in the window does.
use std::collections::HashMap;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use std::time::Duration;
use tauri::{AppHandle, Emitter, Manager};
use tauri_plugin_notification::NotificationExt;
@@ -53,7 +53,7 @@ fn worker(app: AppHandle) {
let mut announcer = Announcer::default();
loop {
std::thread::sleep(TICK);
let now = now_ms();
let now = crate::now_ms();
let due = match due(&app, now) {
Ok(due) => due,
Err(e) => {
@@ -74,7 +74,7 @@ fn due(app: &AppHandle, now: i64) -> Result<Vec<DueReminder>, String> {
}
fn announce(app: &AppHandle, reminder: &DueReminder) {
if let Err(e) = app.emit_to("main", DUE_EVENT, reminder) {
if let Err(e) = app.emit_to(crate::MAIN_WINDOW, DUE_EVENT, reminder) {
log::warn!("could not tell the window about a due reminder: {e}");
}
if main_window_in_front(app) {
@@ -99,16 +99,10 @@ fn announce(app: &AppHandle, reminder: &DueReminder) {
/// Whether someone is looking at the main window right now. Unknown counts as no:
/// a duplicate alert is a smaller failure than a missed one.
fn main_window_in_front(app: &AppHandle) -> bool {
app.get_webview_window("main")
app.get_webview_window(crate::MAIN_WINDOW)
.is_some_and(|w| w.is_focused().unwrap_or(false) && !w.is_minimized().unwrap_or(true))
}
fn now_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as i64)
}
/// Which occurrences have been announced. Keyed by note id, holding the `remind_at`
/// announced: completing or snoozing a reminder changes that value, so the next
/// occurrence is new to it.
+28 -18
View File
@@ -171,6 +171,11 @@ impl Source {
/// reason: this value is spliced into every update URL, and an address carrying a
/// query, a fragment or whitespace is a paste gone wrong rather than a place to
/// fetch from.
///
/// Deliberately stricter than `sync::compat::normalize_base_url`, which tidies what a
/// person types into Sync (it supplies a missing `https://`, and lets a path or
/// query through for the probe to judge). This one checks a value a script wrote,
/// where anything unusual is a fault to refuse rather than a typo to forgive.
fn normalize_server(raw: &str) -> Option<String> {
let trimmed = raw.trim().trim_end_matches('/');
let rest = trimmed
@@ -267,15 +272,8 @@ fn self_update_blocker() -> Option<String> {
/// Windows installer that has no channel concept — and none of that should keep the
/// app from opening.
pub fn adopt_installer_channel(db: &Db, data_dir: &Path) {
let path = data_dir.join(INSTALL_MARKER);
let Ok(raw) = std::fs::read_to_string(&path) else {
return;
};
let Some(channel) = Channel::from_marker(&raw) else {
log::warn!(
"ignoring an unreadable install channel marker at {}",
path.display()
);
let Some(channel) = read_marker(data_dir, INSTALL_MARKER, "channel", Channel::from_marker)
else {
return;
};
match adopt(db, CHANNEL_PREF, CHANNEL_SEED_PREF, channel.as_str()) {
@@ -292,15 +290,7 @@ pub fn adopt_installer_channel(db: &Db, data_dir: &Path) {
/// and for the same reasons: once per new marker value, so a choice made in the app
/// afterwards sticks, and reinstalling from a different server is honoured.
pub fn adopt_installer_server(db: &Db, data_dir: &Path) {
let path = data_dir.join(SERVER_MARKER);
let Ok(raw) = std::fs::read_to_string(&path) else {
return;
};
let Some(server) = normalize_server(&raw) else {
log::warn!(
"ignoring an unreadable install server marker at {}",
path.display()
);
let Some(server) = read_marker(data_dir, SERVER_MARKER, "server", normalize_server) else {
return;
};
match adopt(db, SERVER_PREF, SERVER_SEED_PREF, &server) {
@@ -310,6 +300,26 @@ pub fn adopt_installer_server(db: &Db, data_dir: &Path) {
}
}
/// The installer's marker `name` in `data_dir`, parsed. `None` when there is none,
/// which is ordinary, or when it doesn't parse, which is logged and stepped over.
fn read_marker<T>(
data_dir: &Path,
name: &str,
what: &str,
parse: impl Fn(&str) -> Option<T>,
) -> Option<T> {
let path = data_dir.join(name);
let raw = std::fs::read_to_string(&path).ok()?;
let parsed = parse(&raw);
if parsed.is_none() {
log::warn!(
"ignoring an unreadable install {what} marker at {}",
path.display()
);
}
parsed
}
/// Write `value` to `pref` unless this same marker value was already applied.
/// Returns whether anything changed.
fn adopt(db: &Db, pref: &str, seed_pref: &str, value: &str) -> Result<bool, String> {