Compare commits
10
Commits
74ee288fd4
...
027ad6f672
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
027ad6f672 | ||
|
|
17076d6a79 | ||
|
|
00dc8840cc | ||
|
|
f46dd7a707 | ||
|
|
7ee4fed246 | ||
|
|
9b8c9474bc | ||
|
|
4a810df0a9 | ||
|
|
0d82c17224 | ||
|
|
709ccca64c | ||
|
|
f2fac1674b |
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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, ()) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user