android: share a note with a group
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 1m22s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m39s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m37s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m26s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 8m16s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 1m22s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m39s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m37s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m26s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 8m16s
The Share sheet lists groups after people, shows a group share as its name and how many are in it, and shares through the FFI's ShareTarget. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,12 +29,15 @@ 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.pluralStringResource
|
||||
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
|
||||
import com.fabledsword.inkwell.core.ShareGroup
|
||||
import com.fabledsword.inkwell.core.ShareTarget
|
||||
|
||||
/** "Shared by Robin · view only" over someone else's note; nothing over our own. */
|
||||
@Composable
|
||||
@@ -59,16 +62,16 @@ private const val VIEW = "view"
|
||||
private const val EDIT = "edit"
|
||||
|
||||
/**
|
||||
* The Share sheet: who the note is shared with, and adding someone.
|
||||
* The Share sheet: who the note is shared with, and adding someone or a group.
|
||||
*
|
||||
* 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.
|
||||
* A peer of the web's `ShareDialog.vue`. Each share'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,
|
||||
onShare: (target: ShareTarget, permission: String) -> Unit,
|
||||
onUnshare: (shareId: String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
@@ -96,7 +99,7 @@ fun ShareSheet(
|
||||
@Composable
|
||||
private fun ShareBody(
|
||||
state: ShareState,
|
||||
onShare: (userId: String, permission: String) -> Unit,
|
||||
onShare: (target: ShareTarget, permission: String) -> Unit,
|
||||
onUnshare: (shareId: String) -> Unit,
|
||||
) {
|
||||
if (state.shares.isEmpty()) {
|
||||
@@ -106,7 +109,7 @@ private fun ShareBody(
|
||||
ShareRow(
|
||||
share = share,
|
||||
busy = state.busy,
|
||||
onPermission = { onShare(share.member.id, it) },
|
||||
onPermission = { onShare(share.target, it) },
|
||||
onRemove = { onUnshare(share.id) },
|
||||
)
|
||||
}
|
||||
@@ -127,7 +130,7 @@ private fun ShareRow(
|
||||
) {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
MemberName(share.member, Modifier.weight(1f))
|
||||
TargetName(share.member, share.group, Modifier.weight(1f))
|
||||
IconButton(onClick = onRemove, enabled = !busy) {
|
||||
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.share_remove))
|
||||
}
|
||||
@@ -139,9 +142,9 @@ private fun ShareRow(
|
||||
@Composable
|
||||
private fun AddPerson(
|
||||
state: ShareState,
|
||||
onShare: (userId: String, permission: String) -> Unit,
|
||||
onShare: (target: ShareTarget, permission: String) -> Unit,
|
||||
) {
|
||||
var pick by remember(state.noteId) { mutableStateOf<String?>(null) }
|
||||
var pick by remember(state.noteId) { mutableStateOf<ShareCandidate?>(null) }
|
||||
var permission by remember(state.noteId) { mutableStateOf(VIEW) }
|
||||
|
||||
Text(
|
||||
@@ -152,13 +155,13 @@ private fun AddPerson(
|
||||
// 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 ->
|
||||
items(items = state.available, key = { it.key }) { candidate ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().clickable { pick = member.id },
|
||||
modifier = Modifier.fillMaxWidth().clickable { pick = candidate },
|
||||
) {
|
||||
RadioButton(selected = pick == member.id, onClick = { pick = member.id })
|
||||
MemberName(member, Modifier.weight(1f))
|
||||
RadioButton(selected = pick?.key == candidate.key, onClick = { pick = candidate })
|
||||
TargetName(candidate.member, candidate.group, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,7 +171,7 @@ private fun AddPerson(
|
||||
TextButton(
|
||||
enabled = pick != null && !state.busy,
|
||||
onClick = {
|
||||
pick?.let { onShare(it, permission) }
|
||||
pick?.let { onShare(it.target, permission) }
|
||||
pick = null
|
||||
},
|
||||
) { Text(stringResource(R.string.share_action)) }
|
||||
@@ -197,19 +200,30 @@ private fun PermissionChips(
|
||||
}
|
||||
}
|
||||
|
||||
/** A person (name over email) or a group (name over how many are in it). */
|
||||
@Composable
|
||||
private fun MemberName(
|
||||
member: Member,
|
||||
private fun TargetName(
|
||||
member: Member?,
|
||||
group: ShareGroup?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = group?.name ?: member?.let { it.displayName.ifBlank { it.email } }.orEmpty()
|
||||
val detail =
|
||||
when {
|
||||
group != null ->
|
||||
pluralStringResource(
|
||||
R.plurals.share_group_members,
|
||||
group.memberCount.toInt(),
|
||||
group.memberCount.toInt(),
|
||||
)
|
||||
member != null && member.displayName.isNotBlank() -> member.email
|
||||
else -> null
|
||||
}
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = member.displayName.ifBlank { member.email },
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
if (member.displayName.isNotBlank()) {
|
||||
Text(text = title, style = MaterialTheme.typography.bodyLarge)
|
||||
detail?.let {
|
||||
Text(
|
||||
text = member.email,
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
@@ -10,6 +10,8 @@ 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 com.fabledsword.inkwell.core.ShareGroup
|
||||
import com.fabledsword.inkwell.core.ShareTarget
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -19,6 +21,8 @@ data class ShareState(
|
||||
val noteId: String? = null,
|
||||
val loading: Boolean = false,
|
||||
val members: List<Member> = emptyList(),
|
||||
/** The groups the admin made (#5177); a note shared with one reaches its members. */
|
||||
val groups: List<ShareGroup> = emptyList(),
|
||||
val shares: List<NoteShare> = emptyList(),
|
||||
/** This device has no server, and sharing is between people on one. */
|
||||
val needsServer: Boolean = false,
|
||||
@@ -28,14 +32,36 @@ data class ShareState(
|
||||
val busy: Boolean = false,
|
||||
val error: String? = null,
|
||||
) {
|
||||
/** The people it isn't shared with yet. */
|
||||
val available: List<Member>
|
||||
/** The people and groups it isn't shared with yet, people first. */
|
||||
val available: List<ShareCandidate>
|
||||
get() {
|
||||
val taken = shares.map { it.member.id }.toSet()
|
||||
return members.filterNot { it.id in taken }
|
||||
val people = shares.mapNotNull { it.member?.id }.toSet()
|
||||
val grouped = shares.mapNotNull { it.group?.id }.toSet()
|
||||
return members.filterNot { it.id in people }.map { ShareCandidate(member = it) } +
|
||||
groups.filterNot { it.id in grouped }.map { ShareCandidate(group = it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Someone, or a group, the sheet offers to share with. Exactly one is set. */
|
||||
data class ShareCandidate(
|
||||
val member: Member? = null,
|
||||
val group: ShareGroup? = null,
|
||||
) {
|
||||
val key: String
|
||||
get() = group?.let { "g:${it.id}" } ?: "u:${member?.id}"
|
||||
val target: ShareTarget
|
||||
get() = targetOf(member, group)
|
||||
}
|
||||
|
||||
/** Who an existing share goes to, to change its permission. */
|
||||
val NoteShare.target: ShareTarget
|
||||
get() = targetOf(member, group)
|
||||
|
||||
private fun targetOf(
|
||||
member: Member?,
|
||||
group: ShareGroup?,
|
||||
): ShareTarget = group?.let { ShareTarget.Group(it.id) } ?: ShareTarget.Member(member?.id.orEmpty())
|
||||
|
||||
/**
|
||||
* Sharing a note with other people on the linked server (#5175).
|
||||
*
|
||||
@@ -63,9 +89,14 @@ class ShareViewModel(
|
||||
if (!withContext(Dispatchers.IO) { core.syncStatus().linked }) {
|
||||
state.copy(loading = false, needsServer = true)
|
||||
} else {
|
||||
val members = core.shareDirectory()
|
||||
val directory = core.shareDirectory()
|
||||
val shares = core.noteShares(noteId)
|
||||
state.copy(loading = false, members = members, shares = shares)
|
||||
state.copy(
|
||||
loading = false,
|
||||
members = directory.members,
|
||||
groups = directory.groups,
|
||||
shares = shares,
|
||||
)
|
||||
}
|
||||
} catch (e: CoreException) {
|
||||
state.copy(loading = false, loadError = e.describeShareFailure())
|
||||
@@ -77,11 +108,11 @@ class ShareViewModel(
|
||||
state = ShareState()
|
||||
}
|
||||
|
||||
/** Share with someone, or change what they may do. */
|
||||
/** Share with someone or a group, or change what they may do. */
|
||||
fun share(
|
||||
userId: String,
|
||||
target: ShareTarget,
|
||||
permission: String,
|
||||
) = write { noteId -> core.shareNote(noteId, userId, permission) }
|
||||
) = write { noteId -> core.shareNote(noteId, target, permission) }
|
||||
|
||||
fun unshare(shareId: String) = write { noteId -> core.unshareNote(noteId, shareId) }
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
<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_add">Add someone or a group</string>
|
||||
<string name="share_action">Share</string>
|
||||
<string name="share_remove">Stop sharing</string>
|
||||
<string name="share_can_view">Can view</string>
|
||||
@@ -77,6 +77,10 @@
|
||||
<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>
|
||||
<plurals name="share_group_members">
|
||||
<item quantity="one">Group · %d person</item>
|
||||
<item quantity="other">Group · %d people</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Attachments. A file is stored on the phone at once and uploaded on a later
|
||||
sync, so nothing here talks about the network. -->
|
||||
|
||||
Reference in New Issue
Block a user