diff --git a/app/src/main/java/dev/finalish/core/data/FrameioRepository.kt b/app/src/main/java/dev/finalish/core/data/FrameioRepository.kt index d81b862..c7b0627 100644 --- a/app/src/main/java/dev/finalish/core/data/FrameioRepository.kt +++ b/app/src/main/java/dev/finalish/core/data/FrameioRepository.kt @@ -939,6 +939,10 @@ class FrameioRepository( return dev.finalish.core.model.FileAccess.ofRole(role).also { accessByProject[key] = it } } + /** The [fileAccess] already read this session, without asking Frame.io; null until then. */ + fun cachedFileAccess(account: String, projectId: String): dev.finalish.core.model.FileAccess? = + accessByProject["$account:$projectId"] + private suspend fun myRole(account: String, projectId: String): String? { val accountRoles = accounts().firstOrNull { it.id == account }?.roles.orEmpty() if ("owner" in accountRoles || "admin" in accountRoles) return "owner" diff --git a/app/src/main/java/dev/finalish/core/model/Models.kt b/app/src/main/java/dev/finalish/core/model/Models.kt index 8d413d4..619828e 100644 --- a/app/src/main/java/dev/finalish/core/model/Models.kt +++ b/app/src/main/java/dev/finalish/core/model/Models.kt @@ -707,25 +707,29 @@ data class GroupRole( /** * What the signed-in user may do with the files of a project, from their role there (Frame.io's * "Managing user permissions" guide): `viewer` only views, `commenter` also comments, `edit_only` - * also uploads and manages files but can't share or download, `editor` and `full_access` (and - * account owners and admins) can do everything. An unknown role keeps every action: the server - * still has the last word. + * also uploads and manages files but can't share or download, `editor` can do all that and share and + * download, and only `full_access` (and account owners and admins) also "manage settings and invite + * users", i.e. rename, restrict, archive and delete the project. An unknown role keeps every action: + * the server still has the last word. */ @Immutable data class FileAccess( val comment: Boolean = true, - /** Rename, move, copy, duplicate, delete, upload (also a new version), stack. */ + /** Rename, move, copy, duplicate, delete, upload (also a new version), stack; restore deleted files. */ val manage: Boolean = true, val share: Boolean = true, val download: Boolean = true, + /** The project itself: rename, restrict, archive, delete. */ + val projectSettings: Boolean = true, ) { companion object { val Full = FileAccess() fun ofRole(role: String?): FileAccess = when (role) { - "viewer" -> FileAccess(comment = false, manage = false, share = false, download = false) - "commenter" -> FileAccess(comment = true, manage = false, share = false, download = false) - "edit_only" -> FileAccess(comment = true, manage = true, share = false, download = false) + "viewer" -> FileAccess(comment = false, manage = false, share = false, download = false, projectSettings = false) + "commenter" -> FileAccess(comment = true, manage = false, share = false, download = false, projectSettings = false) + "edit_only" -> FileAccess(comment = true, manage = true, share = false, download = false, projectSettings = false) + "editor" -> FileAccess(projectSettings = false) else -> Full } } diff --git a/app/src/main/java/dev/finalish/ui/browser/FolderScreen.kt b/app/src/main/java/dev/finalish/ui/browser/FolderScreen.kt index 2bb4e24..99efd28 100644 --- a/app/src/main/java/dev/finalish/ui/browser/FolderScreen.kt +++ b/app/src/main/java/dev/finalish/ui/browser/FolderScreen.kt @@ -296,7 +296,9 @@ fun FolderScreen( filterCount = fieldFilters.size, onFilters = { filtersOpen = true }, projectActions = ProjectActions( - { onOpenMembers(project.ifBlank { title }) }, { onOpenTrash(project.ifBlank { title }) }, { onOpenShares(project.ifBlank { title }) }, + { onOpenMembers(project.ifBlank { title }) }, + trash = if (fileAccess.manage) ({ onOpenTrash(project.ifBlank { title }) }) else null, + shares = if (fileAccess.share) ({ onOpenShares(project.ifBlank { title }) }) else null, folderMembers = if (isProjectRoot) null else ({ onOpenFolderMembers(folderId, title) }), ), ) @@ -685,7 +687,8 @@ fun FolderScreen( } } -class ProjectActions(val members: () -> Unit, val trash: () -> Unit, val shares: () -> Unit, val folderMembers: (() -> Unit)? = null) +/** The project "⋯" in the top bar; a null action is one the user's role can't use (see [projectActionsFor][dev.finalish.ui.home.projectActionsFor]). */ +class ProjectActions(val members: () -> Unit, val trash: (() -> Unit)?, val shares: (() -> Unit)?, val folderMembers: (() -> Unit)? = null) @Composable private fun ImportFromLinkDialog(onDismiss: () -> Unit, onImport: (url: String, name: String) -> Unit) { @@ -790,8 +793,12 @@ private fun BrowserTopBar( DropdownMenuItem(text = { Text(stringResource(R.string.browser_folder_members)) }, leadingIcon = { Icon(Icons.Rounded.FolderShared, null) }, onClick = { projectMenu = false; fm() }) } DropdownMenuItem(text = { Text(stringResource(if (projectActions.folderMembers != null) R.string.browser_project_members else R.string.browser_action_members)) }, leadingIcon = { Icon(Icons.Rounded.Group, null) }, onClick = { projectMenu = false; projectActions.members() }) - DropdownMenuItem(text = { Text(stringResource(R.string.browser_share_links)) }, leadingIcon = { Icon(Icons.Rounded.Link, null) }, onClick = { projectMenu = false; projectActions.shares() }) - DropdownMenuItem(text = { Text(stringResource(R.string.browser_recently_deleted)) }, leadingIcon = { Icon(Icons.Rounded.DeleteSweep, null) }, onClick = { projectMenu = false; projectActions.trash() }) + projectActions.shares?.let { shares -> + DropdownMenuItem(text = { Text(stringResource(R.string.browser_share_links)) }, leadingIcon = { Icon(Icons.Rounded.Link, null) }, onClick = { projectMenu = false; shares() }) + } + projectActions.trash?.let { trash -> + DropdownMenuItem(text = { Text(stringResource(R.string.browser_recently_deleted)) }, leadingIcon = { Icon(Icons.Rounded.DeleteSweep, null) }, onClick = { projectMenu = false; trash() }) + } } } } diff --git a/app/src/main/java/dev/finalish/ui/home/ProjectSheets.kt b/app/src/main/java/dev/finalish/ui/home/ProjectSheets.kt index d22c44a..b4a9b32 100644 --- a/app/src/main/java/dev/finalish/ui/home/ProjectSheets.kt +++ b/app/src/main/java/dev/finalish/ui/home/ProjectSheets.kt @@ -68,6 +68,23 @@ import dev.finalish.ui.components.RemoteImage import dev.finalish.ui.components.pressable import dev.finalish.ui.theme.Palette +/** A row of the project "⋯" sheet. */ +enum class ProjectAction { Members, ShareLinks, Collections, RecentlyDeleted, SendLink, Rename, Restricted, Archive, Delete } + +/** + * The project actions the user's role allows (see [dev.finalish.core.model.FileAccess]); `null` while + * the role is still loading shows only the rows every role can use. Members and Collections stay for + * every role: viewing them is read-only and Frame.io's guide doesn't restrict it. + */ +fun projectActionsFor(access: dev.finalish.core.model.FileAccess?): List = ProjectAction.entries.filter { action -> + when (action) { + ProjectAction.Members, ProjectAction.Collections -> true + ProjectAction.ShareLinks, ProjectAction.SendLink -> access?.share == true + ProjectAction.RecentlyDeleted -> access?.manage == true + ProjectAction.Rename, ProjectAction.Restricted, ProjectAction.Archive, ProjectAction.Delete -> access?.projectSettings == true + } +} + /** Project "⋯": a sheet with the project's header and its places, with counts (mockup). */ @OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) @Composable @@ -87,6 +104,13 @@ internal fun ProjectMenuSheet( ) { val context = androidx.compose.ui.platform.LocalContext.current val repo = remember { dev.finalish.FinalishApp.container(context).repository } + // The role is read only when the sheet opens (never per card), then kept for the session. + val access by androidx.compose.runtime.produceState( + if (accountId == null) dev.finalish.core.model.FileAccess.Full else repo.cachedFileAccess(accountId, project.id), + accountId, project.id, + ) { + if (value == null && accountId != null) value = repo.fileAccess(accountId, project.id) + } // Counts load in the background; rows show them when they arrive. val shares by androidx.compose.runtime.produceState(null, project.id) { if (accountId != null) repo.shares(accountId, project.id).collect { r -> r.data?.let { value = it.size } } @@ -97,99 +121,133 @@ internal fun ProjectMenuSheet( val members by androidx.compose.runtime.produceState(null, project.id) { if (accountId != null) value = runCatching { repo.members(accountId, "projects", project.id).size }.getOrNull() } + // The project's image, else its latest file (same cover as the card). + val cover by androidx.compose.runtime.produceState( + project.imageRef, + project.id, + ) { + if (value == null && accountId != null) value = runCatching { repo.projectCover(accountId, project).thumbnails.firstOrNull() }.getOrNull() + } + val watch = remember { dev.finalish.FinalishApp.container(context).activityWatch } + val muted by watch.mutedProjects.collectAsStateWithLifecycle() + val notifying = project.id !in muted + // Free and Pro have no restricted projects: the row would only fail. + val noRestricted = "restricted_projects" in dev.finalish.core.network.PlanLimits.missing.collectAsStateWithLifecycle().value[accountId].orEmpty() && + project.restricted != true androidx.compose.material3.ModalBottomSheet( modifier = Modifier.offset(x = dev.finalish.ui.components.LocalSideBarWidth.current / 2), onDismissRequest = onDismiss, sheetState = androidx.compose.material3.rememberModalBottomSheetState(skipPartiallyExpanded = true), containerColor = Palette.Surface2, ) { - Column(Modifier.verticalScroll(androidx.compose.foundation.rememberScrollState()).navigationBarsPadding().padding(bottom = 12.dp)) { - // Header like the official app: cover, name, members · size · bell when notifications are on. - val watch = remember { dev.finalish.FinalishApp.container(context).activityWatch } - val muted by watch.mutedProjects.collectAsStateWithLifecycle() - val notifying = project.id !in muted - Column(Modifier.fillMaxWidth().padding(horizontal = 24.dp).padding(bottom = 12.dp), horizontalAlignment = Alignment.CenterHorizontally) { - Box( - Modifier.size(width = 112.dp, height = 70.dp).clip(RoundedCornerShape(14.dp)).background(projectGradient(project.id)), - contentAlignment = Alignment.BottomStart, - ) { - // The project's image, else its latest file (same cover as the card). - val cover by androidx.compose.runtime.produceState( - project.imageRef, - project.id, - ) { - if (value == null && accountId != null) value = runCatching { repo.projectCover(accountId, project).thumbnails.firstOrNull() }.getOrNull() - } - cover?.let { RemoteImage(it, Modifier.fillMaxSize(), fallback = {}) } - ?: Text(Format.initials(project.name), style = MaterialTheme.typography.titleMedium, color = Color.White, modifier = Modifier.padding(10.dp)) - } - Spacer(Modifier.height(10.dp)) - Text(project.name, style = MaterialTheme.typography.titleLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - listOfNotNull( - workspaceName, - members?.let { pluralStringResource(R.plurals.home_member_count, it, it) }, - Format.bytes(project.storage).takeIf { project.storage > 0 }, - ).joinToString(" · "), - style = MaterialTheme.typography.bodySmall, color = Palette.TextTertiary, - ) - if (notifying) Icon(Icons.Rounded.Notifications, stringResource(R.string.home_notifications_on), tint = Palette.TextTertiary, modifier = Modifier.padding(start = 4.dp).size(14.dp)) - } - } - Row( - Modifier.fillMaxWidth().pressable({ watch.setMuted(project.id, notifying) }, scaleTo = 0.99f).padding(horizontal = 24.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon(Icons.Rounded.NotificationsNone, null, tint = Palette.TextSecondary, modifier = Modifier.size(22.dp)) - Spacer(Modifier.width(16.dp)) - Column(Modifier.weight(1f)) { - Text(stringResource(R.string.home_notifications), style = MaterialTheme.typography.bodyLarge) - Text(stringResource(R.string.home_project_notifications_desc), style = MaterialTheme.typography.bodySmall, color = Palette.TextTertiary) - } - androidx.compose.material3.Switch( - checked = notifying, onCheckedChange = { watch.setMuted(project.id, !it) }, - colors = androidx.compose.material3.SwitchDefaults.colors(checkedTrackColor = Palette.Accent, checkedThumbColor = Palette.OnAccent), - ) - } - HorizontalDivider(color = Palette.Hairline, modifier = Modifier.padding(vertical = 6.dp, horizontal = 24.dp)) - MenuRow(Icons.Rounded.Group, stringResource(R.string.home_members), members, onMembers) - MenuRow(Icons.Rounded.Link, stringResource(R.string.home_share_links), shares, onShares) - MenuRow(Icons.Rounded.ViewCarousel, stringResource(R.string.home_collections), collections, onCollections) - MenuRow(Icons.Rounded.DeleteSweep, stringResource(R.string.home_recently_deleted), null, onTrash) - MenuRow(Icons.Rounded.IosShare, stringResource(R.string.home_send_project_link), null, onClick = { + ProjectMenuContent( + project = project, workspaceName = workspaceName, cover = cover, + members = members, shares = shares, collections = collections, + notifying = notifying, onNotifying = { watch.setMuted(project.id, !it) }, + actions = projectActionsFor(access).filter { it != ProjectAction.Restricted || !noRestricted }, + onShares = onShares, onCollections = onCollections, onMembers = onMembers, onTrash = onTrash, + onSendLink = { val link = "https://next.frame.io/project/${project.id}" val send = android.content.Intent(android.content.Intent.ACTION_SEND).setType("text/plain") .putExtra(android.content.Intent.EXTRA_TEXT, link).putExtra(android.content.Intent.EXTRA_SUBJECT, project.name) context.startActivity(android.content.Intent.createChooser(send, null)) onDismiss() - }) - HorizontalDivider(color = Palette.Hairline, modifier = Modifier.padding(vertical = 6.dp, horizontal = 24.dp)) - MenuRow(Icons.Rounded.Edit, stringResource(R.string.home_rename), null, onRename) - // Free and Pro have no restricted projects: the row would only fail. - val noRestricted = "restricted_projects" in dev.finalish.core.network.PlanLimits.missing.collectAsStateWithLifecycle().value[accountId].orEmpty() && - project.restricted != true - if (!noRestricted) Row( - Modifier.fillMaxWidth().pressable(onRestricted, scaleTo = 0.99f).padding(horizontal = 24.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, + }, + onRename = onRename, onRestricted = onRestricted, onArchive = onArchive, onDelete = onDelete, + ) + } +} + +/** The body of [ProjectMenuSheet]: header, notifications switch and the [actions] rows. */ +@Composable +fun ProjectMenuContent( + project: Project, + workspaceName: String?, + cover: dev.finalish.core.model.ImageRef?, + members: Int?, + shares: Int?, + collections: Int?, + notifying: Boolean, + onNotifying: (Boolean) -> Unit, + actions: List, + onShares: () -> Unit, + onCollections: () -> Unit, + onMembers: () -> Unit, + onTrash: () -> Unit, + onSendLink: () -> Unit, + onRename: () -> Unit, + onRestricted: () -> Unit, + onArchive: () -> Unit, + onDelete: () -> Unit, +) { + Column(Modifier.verticalScroll(androidx.compose.foundation.rememberScrollState()).navigationBarsPadding().padding(bottom = 12.dp)) { + // Header like the official app: cover, name, members · size · bell when notifications are on. + Column(Modifier.fillMaxWidth().padding(horizontal = 24.dp).padding(bottom = 12.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Box( + Modifier.size(width = 112.dp, height = 70.dp).clip(RoundedCornerShape(14.dp)).background(projectGradient(project.id)), + contentAlignment = Alignment.BottomStart, ) { - Icon(Icons.Rounded.Lock, null, tint = Palette.TextSecondary, modifier = Modifier.size(22.dp)) - Spacer(Modifier.width(16.dp)) - Column(Modifier.weight(1f)) { - Text(stringResource(R.string.home_restricted), style = MaterialTheme.typography.bodyLarge) - Text(stringResource(R.string.home_restricted_menu_desc), style = MaterialTheme.typography.bodySmall, color = Palette.TextTertiary) - } - androidx.compose.material3.Switch( - checked = project.restricted == true, onCheckedChange = { onRestricted() }, - colors = androidx.compose.material3.SwitchDefaults.colors(checkedTrackColor = Palette.Accent, checkedThumbColor = Palette.OnAccent), + cover?.let { RemoteImage(it, Modifier.fillMaxSize(), fallback = {}) } + ?: Text(Format.initials(project.name), style = MaterialTheme.typography.titleMedium, color = Color.White, modifier = Modifier.padding(10.dp)) + } + Spacer(Modifier.height(10.dp)) + Text(project.name, style = MaterialTheme.typography.titleLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + listOfNotNull( + workspaceName, + members?.let { pluralStringResource(R.plurals.home_member_count, it, it) }, + Format.bytes(project.storage).takeIf { project.storage > 0 }, + ).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, color = Palette.TextTertiary, ) + if (notifying) Icon(Icons.Rounded.Notifications, stringResource(R.string.home_notifications_on), tint = Palette.TextTertiary, modifier = Modifier.padding(start = 4.dp).size(14.dp)) + } + } + Row( + Modifier.fillMaxWidth().pressable({ onNotifying(!notifying) }, scaleTo = 0.99f).padding(horizontal = 24.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Rounded.NotificationsNone, null, tint = Palette.TextSecondary, modifier = Modifier.size(22.dp)) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + Text(stringResource(R.string.home_notifications), style = MaterialTheme.typography.bodyLarge) + Text(stringResource(R.string.home_project_notifications_desc), style = MaterialTheme.typography.bodySmall, color = Palette.TextTertiary) + } + androidx.compose.material3.Switch( + checked = notifying, onCheckedChange = onNotifying, + colors = androidx.compose.material3.SwitchDefaults.colors(checkedTrackColor = Palette.Accent, checkedThumbColor = Palette.OnAccent), + ) + } + HorizontalDivider(color = Palette.Hairline, modifier = Modifier.padding(vertical = 6.dp, horizontal = 24.dp)) + if (ProjectAction.Members in actions) MenuRow(Icons.Rounded.Group, stringResource(R.string.home_members), members, onMembers) + if (ProjectAction.ShareLinks in actions) MenuRow(Icons.Rounded.Link, stringResource(R.string.home_share_links), shares, onShares) + if (ProjectAction.Collections in actions) MenuRow(Icons.Rounded.ViewCarousel, stringResource(R.string.home_collections), collections, onCollections) + if (ProjectAction.RecentlyDeleted in actions) MenuRow(Icons.Rounded.DeleteSweep, stringResource(R.string.home_recently_deleted), null, onTrash) + if (ProjectAction.SendLink in actions) MenuRow(Icons.Rounded.IosShare, stringResource(R.string.home_send_project_link), null, onSendLink) + val settings = listOf(ProjectAction.Rename, ProjectAction.Restricted, ProjectAction.Archive, ProjectAction.Delete).filter { it in actions } + if (settings.isNotEmpty()) HorizontalDivider(color = Palette.Hairline, modifier = Modifier.padding(vertical = 6.dp, horizontal = 24.dp)) + if (ProjectAction.Rename in settings) MenuRow(Icons.Rounded.Edit, stringResource(R.string.home_rename), null, onRename) + if (ProjectAction.Restricted in settings) Row( + Modifier.fillMaxWidth().pressable(onRestricted, scaleTo = 0.99f).padding(horizontal = 24.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Rounded.Lock, null, tint = Palette.TextSecondary, modifier = Modifier.size(22.dp)) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + Text(stringResource(R.string.home_restricted), style = MaterialTheme.typography.bodyLarge) + Text(stringResource(R.string.home_restricted_menu_desc), style = MaterialTheme.typography.bodySmall, color = Palette.TextTertiary) } - MenuRow( - if (project.status == "inactive") Icons.Rounded.Unarchive else Icons.Rounded.Archive, - if (project.status == "inactive") stringResource(R.string.home_restore) else stringResource(R.string.home_archive), null, onArchive, + androidx.compose.material3.Switch( + checked = project.restricted == true, onCheckedChange = { onRestricted() }, + colors = androidx.compose.material3.SwitchDefaults.colors(checkedTrackColor = Palette.Accent, checkedThumbColor = Palette.OnAccent), ) - MenuRow(Icons.Rounded.Delete, stringResource(R.string.home_delete), null, onDelete, danger = true) } + if (ProjectAction.Archive in settings) MenuRow( + if (project.status == "inactive") Icons.Rounded.Unarchive else Icons.Rounded.Archive, + if (project.status == "inactive") stringResource(R.string.home_restore) else stringResource(R.string.home_archive), null, onArchive, + ) + if (ProjectAction.Delete in settings) MenuRow(Icons.Rounded.Delete, stringResource(R.string.home_delete), null, onDelete, danger = true) } } diff --git a/app/src/test/java/dev/finalish/core/model/FileAccessTest.kt b/app/src/test/java/dev/finalish/core/model/FileAccessTest.kt index 20da950..66737e2 100644 --- a/app/src/test/java/dev/finalish/core/model/FileAccessTest.kt +++ b/app/src/test/java/dev/finalish/core/model/FileAccessTest.kt @@ -2,6 +2,8 @@ package dev.finalish.core.model import dev.finalish.ui.browser.FileAction import dev.finalish.ui.browser.fileActionsFor +import dev.finalish.ui.home.ProjectAction +import dev.finalish.ui.home.projectActionsFor import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -10,16 +12,41 @@ class FileAccessTest { private val file = Asset("f", name = "Cut.mp4", type = "file", mediaType = "video/mp4") @Test fun `roles map to what the permissions guide allows`() { - assertEquals(FileAccess(comment = false, manage = false, share = false, download = false), FileAccess.ofRole("viewer")) - assertEquals(FileAccess(comment = true, manage = false, share = false, download = false), FileAccess.ofRole("commenter")) - assertEquals(FileAccess(comment = true, manage = true, share = false, download = false), FileAccess.ofRole("edit_only")) - listOf("editor", "full_access", "workspace_admin", "admin", "owner").forEach { assertEquals(FileAccess.Full, FileAccess.ofRole(it)) } + assertEquals(FileAccess(comment = false, manage = false, share = false, download = false, projectSettings = false), FileAccess.ofRole("viewer")) + assertEquals(FileAccess(comment = true, manage = false, share = false, download = false, projectSettings = false), FileAccess.ofRole("commenter")) + assertEquals(FileAccess(comment = true, manage = true, share = false, download = false, projectSettings = false), FileAccess.ofRole("edit_only")) + assertEquals(FileAccess(projectSettings = false), FileAccess.ofRole("editor")) + listOf("full_access", "workspace_admin", "admin", "owner").forEach { assertEquals(FileAccess.Full, FileAccess.ofRole(it)) } } @Test fun `an unknown or unreadable role keeps every action`() { assertEquals(FileAccess.Full, FileAccess.ofRole(null)) assertEquals(FileAccess.Full, FileAccess.ofRole("something_new")) assertEquals(FileAction.entries.filter { it != FileAction.Members }, fileActionsFor(file, FileAccess.Full)) + assertEquals(ProjectAction.entries, projectActionsFor(FileAccess.ofRole("something_new"))) + } + + @Test fun `editors keep every file action`() { + assertEquals(fileActionsFor(file, FileAccess.Full), fileActionsFor(file, FileAccess.ofRole("editor"))) + } + + @Test fun `project menu follows the role`() { + val readOnly = listOf(ProjectAction.Members, ProjectAction.Collections) + assertEquals(readOnly, projectActionsFor(FileAccess.ofRole("viewer"))) + assertEquals(readOnly, projectActionsFor(FileAccess.ofRole("commenter"))) + assertEquals( + listOf(ProjectAction.Members, ProjectAction.Collections, ProjectAction.RecentlyDeleted), + projectActionsFor(FileAccess.ofRole("edit_only")), + ) + assertEquals( + listOf(ProjectAction.Members, ProjectAction.ShareLinks, ProjectAction.Collections, ProjectAction.RecentlyDeleted, ProjectAction.SendLink), + projectActionsFor(FileAccess.ofRole("editor")), + ) + listOf("full_access", "owner").forEach { assertEquals(ProjectAction.entries, projectActionsFor(FileAccess.ofRole(it))) } + } + + @Test fun `while the role loads only read-only project rows show`() { + assertEquals(listOf(ProjectAction.Members, ProjectAction.Collections), projectActionsFor(null)) } @Test fun `file menus follow the role`() { diff --git a/app/src/test/java/dev/finalish/ui/screenshots/ComponentsScreenshotTest.kt b/app/src/test/java/dev/finalish/ui/screenshots/ComponentsScreenshotTest.kt index c9f7e4d..f2b2861 100644 --- a/app/src/test/java/dev/finalish/ui/screenshots/ComponentsScreenshotTest.kt +++ b/app/src/test/java/dev/finalish/ui/screenshots/ComponentsScreenshotTest.kt @@ -596,4 +596,19 @@ class ComponentsScreenshotTest { dev.finalish.ui.viewer.MediaUnavailableCard(onOpenOnFrameio = {}, modifier = Modifier.padding(16.dp)) } } + + /** A viewer's project "⋯": only notifications, Members and Collections; no rename, archive, delete or sharing. */ + @Test fun projectMenuViewer() = snap("project_menu_viewer") { + androidx.compose.foundation.layout.Box(Modifier.clip(androidx.compose.foundation.shape.RoundedCornerShape(24.dp)).background(Palette.Surface2).padding(top = 20.dp)) { + dev.finalish.ui.home.ProjectMenuContent( + project = dev.finalish.core.model.Project("p1", name = "Saltmarsh", storage = 2_100_000_000), + workspaceName = "Mosswood Pictures", cover = null, + members = 4, shares = 2, collections = 1, + notifying = true, onNotifying = {}, + actions = dev.finalish.ui.home.projectActionsFor(dev.finalish.core.model.FileAccess.ofRole("viewer")), + onShares = {}, onCollections = {}, onMembers = {}, onTrash = {}, onSendLink = {}, + onRename = {}, onRestricted = {}, onArchive = {}, onDelete = {}, + ) + } + } } diff --git a/app/src/test/screenshots/project_menu_viewer.png b/app/src/test/screenshots/project_menu_viewer.png new file mode 100644 index 0000000..fbbdf79 Binary files /dev/null and b/app/src/test/screenshots/project_menu_viewer.png differ