android: share a note, and read or edit one shared with you
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 4s
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 18s
CI & Build / integration (push) Successful in 1m25s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m39s
Android / Kotlin + Rust (APK) (push) Failing after 4m53s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m42s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m40s
Desktop (Tauri) / Update manifest (push) Successful in 5s
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 4s
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 18s
CI & Build / integration (push) Successful in 1m25s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m39s
Android / Kotlin + Rust (APK) (push) Failing after 4m53s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m42s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m40s
Desktop (Tauri) / Update manifest (push) Successful in 5s
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<Note>, which needs
Note: Debug; they use err().expect() now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -58,6 +58,7 @@ fun NoteEditorScreen(
|
||||
labels: List<Label>,
|
||||
saving: Boolean,
|
||||
error: String?,
|
||||
onShare: () -> Unit,
|
||||
onAction: (EditorAction) -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
@@ -90,7 +91,13 @@ fun NoteEditorScreen(
|
||||
// A note in the trash is a record, not a document: editing one would silently
|
||||
// resurrect work that was meant to be thrown away. It renders read-only, with
|
||||
// Restore and Delete forever as the only things to do with it.
|
||||
val readOnly = note.trashed
|
||||
//
|
||||
// A note shared with us to view is read-only for the same reason it is on the
|
||||
// web: nothing about it is ours to change (#5175). At edit, the text is, and the
|
||||
// rows that are the owner's (tags, reminder, files, previews) stay inert.
|
||||
val access = note.access
|
||||
val readOnly = note.readOnlyHere
|
||||
val ownerControls = note.ownerControlsHere
|
||||
|
||||
// Persist the text, if it changed. The baseline check is what makes "open a
|
||||
// note, read it, back out" write nothing at all — without it every glance
|
||||
@@ -177,6 +184,7 @@ fun NoteEditorScreen(
|
||||
EditorTopBar(
|
||||
note = note,
|
||||
readOnly = readOnly,
|
||||
access = access,
|
||||
onClose = leave,
|
||||
onStartChecklist = {
|
||||
val (next, id) = blocks.plusTask()
|
||||
@@ -185,6 +193,10 @@ fun NoteEditorScreen(
|
||||
},
|
||||
onPicker = { picker = it },
|
||||
onAttach = { pickFiles.launch(ANY_TYPE) },
|
||||
onShare = {
|
||||
flush()
|
||||
onShare()
|
||||
},
|
||||
onConfirmDelete = { confirmingDelete = true },
|
||||
onAction = onAction,
|
||||
)
|
||||
@@ -225,6 +237,9 @@ fun NoteEditorScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Whose note this is, and what may be done with it here.
|
||||
SharedByLine(note)
|
||||
|
||||
// A note is its body; its NAME is that body's first line, so there
|
||||
// is nothing separate to type into and nothing rendered bolder than
|
||||
// the line beneath it (M13 steps 3 and 4). What 2992 changed is only
|
||||
@@ -243,24 +258,28 @@ fun NoteEditorScreen(
|
||||
// list on screen twice.
|
||||
|
||||
if (note.labels.isNotEmpty()) {
|
||||
EditorLabelRow(note = note, readOnly = readOnly, onAction = onAction)
|
||||
EditorLabelRow(note = note, readOnly = !ownerControls, onAction = onAction)
|
||||
}
|
||||
|
||||
note.remindAt?.let { at ->
|
||||
EditorReminderRow(
|
||||
at = at,
|
||||
recurrence = note.recurrence,
|
||||
readOnly = readOnly,
|
||||
readOnly = !ownerControls,
|
||||
onAction = onAction,
|
||||
)
|
||||
}
|
||||
|
||||
if (note.attachments.isNotEmpty()) {
|
||||
EditorAttachments(attachments = note.attachments, readOnly = readOnly, onAction = onAction)
|
||||
EditorAttachments(
|
||||
attachments = note.attachments,
|
||||
readOnly = !ownerControls,
|
||||
onAction = onAction,
|
||||
)
|
||||
}
|
||||
|
||||
if (note.previews.isNotEmpty()) {
|
||||
EditorPreviews(previews = note.previews, readOnly = readOnly, onAction = onAction)
|
||||
EditorPreviews(previews = note.previews, readOnly = !ownerControls, onAction = onAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.inkwell.core.CoreException
|
||||
import com.fabledsword.inkwell.core.Inkwell
|
||||
import com.fabledsword.inkwell.core.Member
|
||||
import com.fabledsword.inkwell.core.NoteShare
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Everything the Share sheet renders from. [noteId] null means the sheet is closed. */
|
||||
data class ShareState(
|
||||
val noteId: String? = null,
|
||||
val loading: Boolean = false,
|
||||
val members: List<Member> = emptyList(),
|
||||
val shares: List<NoteShare> = emptyList(),
|
||||
/** This device has no server, and sharing is between people on one. */
|
||||
val needsServer: Boolean = false,
|
||||
/** Why the sheet couldn't load, when it has a server and still failed. */
|
||||
val loadError: String? = null,
|
||||
/** An in-flight write, for disabling the controls that would race it. */
|
||||
val busy: Boolean = false,
|
||||
val error: String? = null,
|
||||
) {
|
||||
/** The people it isn't shared with yet. */
|
||||
val available: List<Member>
|
||||
get() {
|
||||
val taken = shares.map { it.member.id }.toSet()
|
||||
return members.filterNot { it.id in taken }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sharing a note with other people on the linked server (#5175).
|
||||
*
|
||||
* A peer of the web's `ShareDialog.vue`. Shares belong to the server, so every call
|
||||
* here is a network round-trip through the core — `suspend` functions on uniffi's
|
||||
* tokio runtime, not blocking store calls, so no IO dispatcher is needed. The one
|
||||
* thing the core keeps locally is the note's `shared` flag, which is why a write
|
||||
* that landed tells the board to reload: its card chip reads that flag.
|
||||
*/
|
||||
class ShareViewModel(
|
||||
private val core: Inkwell,
|
||||
/** Called after a share was granted, changed or removed. */
|
||||
private val onStoreChanged: () -> Unit,
|
||||
) : ViewModel() {
|
||||
var state by mutableStateOf(ShareState())
|
||||
private set
|
||||
|
||||
fun open(noteId: String) {
|
||||
state = ShareState(noteId = noteId, loading = true)
|
||||
viewModelScope.launch {
|
||||
state =
|
||||
try {
|
||||
val members = core.shareDirectory()
|
||||
val shares = core.noteShares(noteId)
|
||||
state.copy(loading = false, members = members, shares = shares)
|
||||
} catch (e: CoreException) {
|
||||
// Unlinked is not a failure: it is the phone working as intended,
|
||||
// and the sheet says what sharing needs instead of an error.
|
||||
if (e is CoreException.NotLinked) {
|
||||
state.copy(loading = false, needsServer = true)
|
||||
} else {
|
||||
state.copy(loading = false, loadError = e.describeShareFailure())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
state = ShareState()
|
||||
}
|
||||
|
||||
/** Share with someone, or change what they may do. */
|
||||
fun share(
|
||||
userId: String,
|
||||
permission: String,
|
||||
) = write { noteId -> core.shareNote(noteId, userId, permission) }
|
||||
|
||||
fun unshare(shareId: String) = write { noteId -> core.unshareNote(noteId, shareId) }
|
||||
|
||||
private fun write(call: suspend (String) -> List<NoteShare>) {
|
||||
val noteId = state.noteId ?: return
|
||||
state = state.copy(busy = true, error = null)
|
||||
viewModelScope.launch {
|
||||
state =
|
||||
try {
|
||||
val shares = call(noteId)
|
||||
onStoreChanged()
|
||||
state.copy(busy = false, shares = shares)
|
||||
} catch (e: CoreException) {
|
||||
state.copy(busy = false, error = e.describeShareFailure())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun factory(
|
||||
core: Inkwell,
|
||||
onStoreChanged: () -> Unit,
|
||||
): ViewModelProvider.Factory =
|
||||
object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = ShareViewModel(core, onStoreChanged) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The core's message is written to be shown ("The server doesn't have this note yet…"). */
|
||||
private fun Throwable.describeShareFailure(): String = message ?: "Something went wrong."
|
||||
@@ -0,0 +1,263 @@
|
||||
package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
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
|
||||
import androidx.compose.runtime.Composable
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.inkwell.R
|
||||
import com.fabledsword.inkwell.core.Member
|
||||
import com.fabledsword.inkwell.core.Note
|
||||
import com.fabledsword.inkwell.core.NoteShare
|
||||
|
||||
/**
|
||||
* How this account holds a note (#5175), read from the core's `permission`.
|
||||
*
|
||||
* Someone else's note at [EDIT] is theirs to have its TEXT changed here and nothing
|
||||
* else; at [VIEW] nothing changes here at all. The core refuses the rest anyway —
|
||||
* this only keeps the editor from offering what would be refused.
|
||||
*/
|
||||
enum class NoteAccess { OWNER, EDIT, VIEW }
|
||||
|
||||
val Note.access: NoteAccess
|
||||
get() =
|
||||
when (permission) {
|
||||
"edit" -> NoteAccess.EDIT
|
||||
"view" -> NoteAccess.VIEW
|
||||
else -> NoteAccess.OWNER
|
||||
}
|
||||
|
||||
/** Nothing changes here: the note is in the trash, or shared with us to view. */
|
||||
val Note.readOnlyHere: Boolean
|
||||
get() = trashed || access == NoteAccess.VIEW
|
||||
|
||||
/** The owner's own controls apply: tags, reminder, files, previews, the menu. */
|
||||
val Note.ownerControlsHere: Boolean
|
||||
get() = !readOnlyHere && access == NoteAccess.OWNER
|
||||
|
||||
/** "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 line =
|
||||
if (note.access == NoteAccess.EDIT) {
|
||||
stringResource(R.string.share_by_can_edit, who)
|
||||
} else {
|
||||
stringResource(R.string.share_by_view_only, who)
|
||||
}
|
||||
Text(
|
||||
text = line,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
private const val VIEW = "view"
|
||||
private const val EDIT = "edit"
|
||||
|
||||
/**
|
||||
* The Share sheet: who the note is shared with, and adding someone.
|
||||
*
|
||||
* A peer of the web's `ShareDialog.vue`. Each person's permission is a pair of
|
||||
* chips rather than a menu, because there are exactly two and both fit.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ShareSheet(
|
||||
state: ShareState,
|
||||
onShare: (userId: String, permission: String) -> Unit,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShareBody(
|
||||
state: ShareState,
|
||||
onShare: (userId: String, permission: String) -> Unit,
|
||||
onUnshare: (shareId: String) -> Unit,
|
||||
) {
|
||||
if (state.shares.isEmpty()) {
|
||||
Muted(stringResource(R.string.share_none))
|
||||
}
|
||||
state.shares.forEach { share ->
|
||||
ShareRow(
|
||||
share = share,
|
||||
busy = state.busy,
|
||||
onPermission = { onShare(share.member.id, it) },
|
||||
onRemove = { onUnshare(share.id) },
|
||||
)
|
||||
}
|
||||
if (state.available.isEmpty()) {
|
||||
Muted(stringResource(R.string.share_everyone))
|
||||
} else {
|
||||
AddPerson(state = state, onShare = onShare)
|
||||
}
|
||||
state.error?.let { ErrorText(it) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShareRow(
|
||||
share: NoteShare,
|
||||
busy: Boolean,
|
||||
onPermission: (String) -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
MemberName(share.member, Modifier.weight(1f))
|
||||
IconButton(onClick = onRemove, enabled = !busy) {
|
||||
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.share_remove))
|
||||
}
|
||||
}
|
||||
PermissionChips(selected = share.permission, enabled = !busy, onSelect = onPermission)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddPerson(
|
||||
state: ShareState,
|
||||
onShare: (userId: String, permission: String) -> Unit,
|
||||
) {
|
||||
var pick by remember(state.noteId) { mutableStateOf<String?>(null) }
|
||||
var permission by remember(state.noteId) { mutableStateOf(VIEW) }
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.share_add),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
// Capped, like the tag picker: a sheet that grows past the screen makes its own
|
||||
// scroll fight the sheet's drag.
|
||||
LazyColumn(modifier = Modifier.heightIn(max = MEMBER_LIST_MAX_HEIGHT)) {
|
||||
items(items = state.available, key = { it.id }) { member ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().clickable { pick = member.id },
|
||||
) {
|
||||
RadioButton(selected = pick == member.id, onClick = { pick = member.id })
|
||||
MemberName(member, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
PermissionChips(selected = permission, enabled = !state.busy, onSelect = { permission = it })
|
||||
Spacer(Modifier.weight(1f))
|
||||
TextButton(
|
||||
enabled = pick != null && !state.busy,
|
||||
onClick = {
|
||||
pick?.let { onShare(it, permission) }
|
||||
pick = null
|
||||
},
|
||||
) { Text(stringResource(R.string.share_action)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionChips(
|
||||
selected: String,
|
||||
enabled: Boolean,
|
||||
onSelect: (String) -> Unit,
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(
|
||||
selected = selected == VIEW,
|
||||
enabled = enabled,
|
||||
onClick = { onSelect(VIEW) },
|
||||
label = { Text(stringResource(R.string.share_can_view)) },
|
||||
)
|
||||
FilterChip(
|
||||
selected = selected == EDIT,
|
||||
enabled = enabled,
|
||||
onClick = { onSelect(EDIT) },
|
||||
label = { Text(stringResource(R.string.share_can_edit)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MemberName(
|
||||
member: Member,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = member.displayName.ifBlank { member.email },
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
if (member.displayName.isNotBlank()) {
|
||||
Text(
|
||||
text = member.email,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Muted(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorText(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
private val MEMBER_LIST_MAX_HEIGHT = 240.dp
|
||||
@@ -58,6 +58,25 @@
|
||||
<string name="editor_trash">Move to trash</string>
|
||||
<string name="editor_restore">Restore</string>
|
||||
<string name="editor_cancel">Cancel</string>
|
||||
<string name="editor_share">Share…</string>
|
||||
|
||||
<!-- Sharing (#5175). Shares live on the server, so the sheet says so when this
|
||||
phone isn't linked to one. -->
|
||||
<string name="share_title">Share</string>
|
||||
<string name="share_loading">Loading…</string>
|
||||
<string name="share_needs_server">Sharing is between people on a server. Link this phone in Sync to share notes.</string>
|
||||
<string name="share_none">Not shared with anyone yet.</string>
|
||||
<string name="share_everyone">Shared with everyone on this server.</string>
|
||||
<string name="share_add">Add someone</string>
|
||||
<string name="share_action">Share</string>
|
||||
<string name="share_remove">Stop sharing</string>
|
||||
<string name="share_can_view">Can view</string>
|
||||
<string name="share_can_edit">Can edit</string>
|
||||
<string name="share_someone">someone</string>
|
||||
<string name="share_by_view_only">Shared by %1$s · view only</string>
|
||||
<string name="share_by_can_edit">Shared by %1$s · you can edit the text</string>
|
||||
<string name="share_chip_shared">Shared</string>
|
||||
<string name="share_chip_by">From %1$s</string>
|
||||
|
||||
<!-- Attachments. A file is stored on the phone at once and uploaded on a later
|
||||
sync, so nothing here talks about the network. -->
|
||||
|
||||
@@ -1314,7 +1314,9 @@ mod tests {
|
||||
fn a_view_share_changes_nothing_here() {
|
||||
let conn = db();
|
||||
shared(&conn, "n", "view");
|
||||
let refused = update_note(&conn, "n", &json!({ "body": "mine now" })).unwrap_err();
|
||||
let refused = update_note(&conn, "n", &json!({ "body": "mine now" }))
|
||||
.err()
|
||||
.expect("refused");
|
||||
// The words the person sees, exactly: the refusal prints as its message.
|
||||
assert_eq!(refused.to_string(), VIEW_ONLY);
|
||||
assert!(add_item(&conn, "n", "eggs").is_err());
|
||||
@@ -1338,7 +1340,9 @@ mod tests {
|
||||
assert!(dirty(&conn, "n"));
|
||||
add_item(&conn, "n", "eggs").expect("an item is text");
|
||||
|
||||
let pinned = update_note(&conn, "n", &json!({ "pinned": true })).unwrap_err();
|
||||
let pinned = update_note(&conn, "n", &json!({ "pinned": true }))
|
||||
.err()
|
||||
.expect("refused");
|
||||
assert_eq!(pinned.to_string(), OWNER_ONLY);
|
||||
assert!(update_note(&conn, "n", &json!({ "body": "x", "archived": true })).is_err());
|
||||
assert!(trash(&conn, "n").is_err());
|
||||
|
||||
Reference in New Issue
Block a user