From 2e7db21b218b6c4f4857245a40a14baa964fa8e2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 7 Oct 2026 15:42:30 -0400 Subject: [PATCH] android: share a note, and read or edit one shared with you MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor's menu has Share…, which opens a sheet of who the note is shared with and lets you add someone at view or edit, change it, or stop sharing; unlinked, it says sharing needs a server. A note shared to view opens read-only; at edit only its text can change. Cards say who shared a note ("From Robin") or that yours is shared, and a view-only note's boxes don't tick. Also: two core store tests used unwrap_err on a Result, which needs Note: Debug; they use err().expect() now. Co-Authored-By: Claude Opus 5.5 --- .../com/fabledsword/inkwell/MainActivity.kt | 18 ++ .../fabledsword/inkwell/ui/EditorChrome.kt | 38 ++- .../fabledsword/inkwell/ui/EditorPickers.kt | 2 +- .../com/fabledsword/inkwell/ui/NoteCard.kt | 49 +++- .../inkwell/ui/NoteEditorScreen.kt | 29 +- .../fabledsword/inkwell/ui/ShareViewModel.kt | 114 ++++++++ .../com/fabledsword/inkwell/ui/Sharing.kt | 263 ++++++++++++++++++ android/app/src/main/res/values/strings.xml | 19 ++ core/src/local/store.rs | 8 +- 9 files changed, 519 insertions(+), 21 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/inkwell/ui/ShareViewModel.kt create mode 100644 android/app/src/main/java/com/fabledsword/inkwell/ui/Sharing.kt diff --git a/android/app/src/main/java/com/fabledsword/inkwell/MainActivity.kt b/android/app/src/main/java/com/fabledsword/inkwell/MainActivity.kt index 2cbdae9..e8db24c 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/MainActivity.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/MainActivity.kt @@ -35,6 +35,8 @@ import com.fabledsword.inkwell.ui.ForegroundTransitions import com.fabledsword.inkwell.ui.InkwellTheme import com.fabledsword.inkwell.ui.LocalAttachmentFiles import com.fabledsword.inkwell.ui.NoteEditorScreen +import com.fabledsword.inkwell.ui.ShareSheet +import com.fabledsword.inkwell.ui.ShareViewModel import com.fabledsword.inkwell.ui.StoreUnavailableScreen import com.fabledsword.inkwell.ui.SyncScreen import com.fabledsword.inkwell.ui.SyncState @@ -266,6 +268,11 @@ private fun App( val tags: TagsViewModel = viewModel(factory = TagsViewModel.factory(core, onStoreChanged = board::refreshLabels)) + // A share landing changes the note's `shared` flag, which the board's card chip + // reads, so the board reloads after one. + val share: ShareViewModel = + viewModel(factory = ShareViewModel.factory(core, onStoreChanged = board::refresh)) + val update: UpdateViewModel = viewModel(factory = UpdateViewModel.factory(core, context)) val settings = remember(context) { SyncSettings(context) } var automatic by remember { mutableStateOf(settings.automatic) } @@ -327,6 +334,7 @@ private fun App( labels = board.state.labels, saving = board.state.saving, error = board.state.error, + onShare = { share.open(editing.id) }, // The one seam between the editor and the store. Exhaustive at the // other end, so a new action cannot be added without being handled. onAction = { board.onEditorAction(editing, it) }, @@ -376,6 +384,16 @@ private fun App( } } + // Over whichever screen opened it; a ModalBottomSheet handles its own back. + if (share.state.noteId != null) { + ShareSheet( + state = share.state, + onShare = share::share, + onUnshare = share::unshare, + onDismiss = share::close, + ) + } + // The sync screen has no back handler of its own, so one lives here. The // editor keeps its own, because it has to save the open note before leaving. BackHandler(enabled = showingSync) { showingSync = false } diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/EditorChrome.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/EditorChrome.kt index b456fbe..bc84244 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/EditorChrome.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/EditorChrome.kt @@ -76,13 +76,18 @@ import com.fabledsword.inkwell.core.Note fun EditorTopBar( note: Note, readOnly: Boolean, + access: NoteAccess, onClose: () -> Unit, onStartChecklist: () -> Unit, onPicker: (Picker) -> Unit, onAttach: () -> Unit, + onShare: () -> Unit, onConfirmDelete: () -> Unit, onAction: (EditorAction) -> Unit, ) { + // Someone else's note (#5175): its text is all that may change here, so the bar + // keeps the checklist button and nothing that is the owner's to do. + val owner = access == NoteAccess.OWNER val dark = isSystemInDarkTheme() TopAppBar( title = {}, @@ -98,13 +103,15 @@ fun EditorTopBar( } }, actions = { - if (!readOnly) { + if (!readOnly && owner) { IconButton(onClick = { onPicker(Picker.REMINDER) }) { Icon( Icons.Filled.Notifications, contentDescription = stringResource(R.string.editor_reminder), ) } + } + if (!readOnly) { // Inserts `- [ ] ` at the caret. Always available, and never hidden: // a checklist is text now (M304), so there is no section to be // already-showing and no reason a second list cannot start further @@ -115,9 +122,10 @@ fun EditorTopBar( contentDescription = stringResource(R.string.editor_add_checklist), ) } - // On the bar rather than in the overflow: attaching a photo is - // something people look for, and a menu of words is where it would - // not be found. + } + // On the bar rather than in the overflow: attaching a photo is something + // people look for, and a menu of words is where it would not be found. + if (!readOnly && owner) { IconButton(onClick = onAttach) { Icon( painter = painterResource(R.drawable.ic_attach), @@ -125,13 +133,16 @@ fun EditorTopBar( ) } } - OverflowMenu( - note = note, - readOnly = readOnly, - onPicker = onPicker, - onConfirmDelete = onConfirmDelete, - onAction = onAction, - ) + if (owner) { + OverflowMenu( + note = note, + readOnly = readOnly, + onPicker = onPicker, + onShare = onShare, + onConfirmDelete = onConfirmDelete, + onAction = onAction, + ) + } }, // EXPLICIT, and not optional — the same lesson the old bottom bar learned. // Material derives a bar's content colour from its container via @@ -269,6 +280,7 @@ private fun OverflowMenu( note: Note, readOnly: Boolean, onPicker: (Picker) -> Unit, + onShare: () -> Unit, onConfirmDelete: () -> Unit, onAction: (EditorAction) -> Unit, ) { @@ -288,6 +300,10 @@ private fun OverflowMenu( close, ) { onAction(EditorAction.SetPinned(!note.pinned)) } MenuItem(R.string.editor_labels, close) { onPicker(Picker.LABELS) } + // Not on a draft: the server has nothing to share until it is saved. + if (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, diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/EditorPickers.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/EditorPickers.kt index 6abb324..73baf1b 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/EditorPickers.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/EditorPickers.kt @@ -338,7 +338,7 @@ private fun RecurrenceChips( } @Composable -private fun SheetTitle(labelRes: Int) { +internal fun SheetTitle(labelRes: Int) { Text( text = stringResource(labelRes), style = MaterialTheme.typography.titleMedium, diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteCard.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteCard.kt index 8ddefee..04ca2e9 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteCard.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteCard.kt @@ -136,7 +136,12 @@ fun NoteCard( if (lonePreview != null) { LinkPreviewCard(preview = lonePreview, compact = false) } else if (note.body.isNotBlank()) { - NoteBody(note = note, onToggleItem = onToggleItem) + // A note shared with us to view has inert boxes (#5175): ticking one is + // changing its text, which is not ours to do. + NoteBody( + note = note, + onToggleItem = if (note.access == NoteAccess.VIEW) INERT_TOGGLE else onToggleItem, + ) } // Links mentioned INSIDE a note: a compact strip at the foot of the card, @@ -172,6 +177,8 @@ fun NoteCard( Spacer(Modifier.height(8.dp)) ReminderChip(instant = at, recurrence = note.recurrence) } + + SharedChip(note) } NoteMenu( @@ -222,7 +229,9 @@ private fun NoteMenu( onAction: (EditorAction) -> Unit, onConfirmDelete: () -> Unit, ) { - DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { + // Every item here is the owner's to do, so someone else's note has no menu at all + // rather than an empty one (#5175). + DropdownMenu(expanded = expanded && note.access == NoteAccess.OWNER, onDismissRequest = onDismiss) { if (note.trashed) { MenuItem(R.string.editor_restore, onDismiss) { onAction(EditorAction.Restore) } MenuItem(R.string.editor_delete_forever, onDismiss, onConfirmDelete) @@ -445,6 +454,42 @@ private fun ReminderChip( ) } +/** + * Whose note this is, when it is not only ours (#5175): "From Robin" on a note someone + * shared with us, "Shared" on one of ours that we shared. Nothing on a private note. + */ +@Composable +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), + ) + 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), + ) +} + +/** A box that does nothing when tapped: someone else's note, shared to view. */ +private val INERT_TOGGLE: (Int, Boolean) -> Unit = { _, _ -> } + private const val MAX_PREVIEW_LINES = 8 /** How far one long line of a card may wrap before it is cut. */ diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteEditorScreen.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteEditorScreen.kt index cc55f8d..f3d4143 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteEditorScreen.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/NoteEditorScreen.kt @@ -58,6 +58,7 @@ fun NoteEditorScreen( labels: List