From 993d5d83e1e49d044c8fcf929f32d512b411048f Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Sat, 4 Jul 2026 18:52:57 +0200 Subject: [PATCH] feat: clearer shared calendars + move group switch into the menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Android UX fixes reported from on-device testing: 1. A calendar shared with the user now shows read-only affordances: parse the server's new read_only flag on events, show a lock icon in the filter sheet, and exclude read-only shared calendars from the event editor's calendar picker (getWritableCalendars) so saves no longer 403. The calendar's display name (owner's name) comes from the server. 2. (server-side) group event name prefix removed — no client change needed. 3. Move the personal/group view switcher out of the crowded top bar into the burger MenuSheet as a "group view" section (personal + each group with a check mark), mirroring iOS. The GroupSwitcher pill and its top-bar wiring are gone; the active-group banner stays. Co-Authored-By: Claude Opus 4.8 --- .../calendarr/data/CalendarRepository.kt | 11 +++- .../calendarr/domain/model/CalEvent.kt | 3 + .../java/com/scarriffle/calendarr/ui/L10n.kt | 2 + .../ui/calendar/CalendarFilterSheet.kt | 11 +++- .../calendarr/ui/calendar/CalendarScreen.kt | 60 ++---------------- .../scarriffle/calendarr/ui/menu/MenuSheet.kt | 61 ++++++++++++++++++- 6 files changed, 87 insertions(+), 61 deletions(-) diff --git a/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt b/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt index 08cb250..fcaa574 100644 --- a/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt +++ b/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt @@ -270,9 +270,14 @@ class CalendarRepository @Inject constructor( /** Resolve all calendars the user can create events in. */ suspend fun getWritableCalendars(): List = withContext(Dispatchers.IO) { val result = mutableListOf() - runCatching { api.getLocalCalendars() }.getOrDefault(emptyList()).forEach { cal -> - result += WritableCalendar("local-${cal.id}", cal.name, cal.color, "local", cal.id) - } + runCatching { api.getLocalCalendars() }.getOrDefault(emptyList()) + // Exclude read-only shared calendars — offering them in the event + // editor only leads to a 403 on save. Own + read_write (incl. group) + // calendars stay. + .filter { it.owned || it.permission == "read_write" } + .forEach { cal -> + result += WritableCalendar("local-${cal.id}", cal.name, cal.color, "local", cal.id) + } runCatching { api.getCalDAVAccounts() }.getOrDefault(emptyList()) .filter { it.enabled } .forEach { acc -> diff --git a/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt b/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt index 5664219..4bb0ff6 100644 --- a/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt +++ b/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt @@ -36,6 +36,8 @@ data class CalEvent( val displayTitle: String? = null, // Reminder offsets in minutes-before-start (0 = at start). Local events only. val reminders: List = emptyList(), + // True for events from a calendar shared with the user read-only. + val readOnly: Boolean = false, ) { /** * Group view supplies a server-resolved colour (display_color); otherwise @@ -126,6 +128,7 @@ data class CalEvent( reminders = json.optJSONArray("reminders")?.let { arr -> (0 until arr.length()).mapNotNull { (arr.opt(it) as? Number)?.toInt() } } ?: emptyList(), + readOnly = json.optBoolean("read_only", false), ) } } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt index d720c05..2374e82 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt @@ -124,6 +124,7 @@ object L10n { "filter.button" to "Kalender ein-/ausblenden", "filter.sync_error" to "Synchronisierung fehlgeschlagen", "filter.banish" to "Dauerhaft ausblenden", + "filter.read_only" to "Nur lesen", "filter.banished_footer" to "Dauerhaft ausgeblendete Kalender erscheinen unter »Konten & Kalender« und können dort wieder eingeblendet werden.", "accounts.banished_header" to "Ausgeblendete Kalender", "accounts.banished_unhide" to "Wieder einblenden", @@ -268,6 +269,7 @@ object L10n { "filter.button" to "Show/hide calendars", "filter.sync_error" to "Sync failed", "filter.banish" to "Hide permanently", + "filter.read_only" to "Read-only", "filter.banished_footer" to "Permanently hidden calendars appear under “Accounts & Calendars”, where you can show them again.", "accounts.banished_header" to "Hidden calendars", "accounts.banished_unhide" to "Show again", diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFilterSheet.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFilterSheet.kt index c0a24e3..2959ca4 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFilterSheet.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFilterSheet.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Archive +import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.NotificationsOff import androidx.compose.material.icons.filled.WarningAmber @@ -33,7 +34,7 @@ import androidx.compose.ui.unit.dp import com.scarriffle.calendarr.ui.tr import com.scarriffle.calendarr.util.colorFromHex -data class CalendarFilterEntry(val key: String, val name: String, val color: String, val source: String = "") +data class CalendarFilterEntry(val key: String, val name: String, val color: String, val source: String = "", val readOnly: Boolean = false) @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -109,6 +110,14 @@ fun CalendarFilterSheet( modifier = Modifier.weight(1f).padding(start = 12.dp), style = MaterialTheme.typography.bodyLarge, ) + if (entry.readOnly) { + Icon( + Icons.Filled.Lock, + contentDescription = tr("filter.read_only"), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 4.dp).size(15.dp), + ) + } if (!groupMode) { val remDisabled = entry.key in state.reminderDisabledKeys IconButton(onClick = { vm.setCalendarRemindersDisabled(entry.key, disabled = !remDisabled) }) { diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt index 163a982..b968596 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt @@ -18,12 +18,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.People import androidx.compose.material.icons.filled.Today import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu @@ -156,9 +154,6 @@ fun CalendarScreen( viewType = state.viewType, loading = state.isLoading || state.isBackgroundCaching, viewMenuOpen = viewMenuOpen, - groups = state.groups, - activeGroup = state.activeGroup, - onSwitchGroup = { vm.switchGroup(it) }, onMenu = { showMenu = true }, onPrev = { goPrev() }, onToday = { goToday() }, @@ -213,6 +208,9 @@ fun CalendarScreen( if (showMenu) { MenuSheet( isAdmin = false, + groups = state.groups, + activeGroup = state.activeGroup, + onSwitchGroup = { showMenu = false; vm.switchGroup(it) }, onDismiss = { showMenu = false }, onProfile = { showMenu = false; overlay = Overlay.PROFILE }, onAppearance = { showMenu = false; overlay = Overlay.SETTINGS }, @@ -398,9 +396,6 @@ private fun CompactTopBar( viewType: CalViewType, loading: Boolean, viewMenuOpen: Boolean, - groups: List, - activeGroup: Group?, - onSwitchGroup: (Group?) -> Unit, onMenu: () -> Unit, onPrev: () -> Unit, onToday: () -> Unit, @@ -437,9 +432,6 @@ private fun CompactTopBar( strokeWidth = 2.dp, ) } - if (groups.isNotEmpty()) { - GroupSwitcher(groups = groups, activeGroup = activeGroup, onSwitchGroup = onSwitchGroup) - } CompactIcon(Icons.Filled.FilterList, onFilter, tr("filter.button")) Box { CompactIcon(viewType.icon, { onViewMenuToggle(true) }, tr("view.change")) @@ -474,50 +466,6 @@ private fun CompactIcon( } } -/** - * Top-bar switcher: "My calendar" + each group; flips the calendar into the - * group overlay. Rendered as a tonal pill so it stands out from the flat icons - * (filled in the accent colour while a group overlay is active). - */ -@Composable -private fun GroupSwitcher(groups: List, activeGroup: Group?, onSwitchGroup: (Group?) -> Unit) { - var open by remember { mutableStateOf(false) } - val active = activeGroup != null - Box { - Box( - Modifier - .padding(horizontal = 2.dp) - .size(38.dp) - .clip(CircleShape) - .background(if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant) - .clickable { open = true }, - contentAlignment = Alignment.Center, - ) { - Icon( - Icons.Filled.People, - contentDescription = tr("groups.title"), - modifier = Modifier.size(21.dp), - tint = if (active) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - DropdownMenu(expanded = open, onDismissRequest = { open = false }) { - DropdownMenuItem( - text = { Text(tr("group.switch.personal")) }, - trailingIcon = { if (!active) Icon(Icons.Filled.Check, contentDescription = null) }, - onClick = { open = false; onSwitchGroup(null) }, - ) - groups.forEach { g -> - DropdownMenuItem( - text = { Text(g.name) }, - leadingIcon = { GroupIcon(g.icon) }, - trailingIcon = { if (activeGroup?.id == g.id) Icon(Icons.Filled.Check, contentDescription = null) }, - onClick = { open = false; onSwitchGroup(g) }, - ) - } - } - } -} - @Composable private fun GroupBanner(group: Group, onExit: () -> Unit) { Surface(color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f), modifier = Modifier.fillMaxWidth()) { @@ -543,7 +491,7 @@ private fun GroupBanner(group: Group, onExit: () -> Unit) { * can be toggled back on. */ private fun allKnownCalendars(vm: CalendarViewModel): List { return vm.knownCalendars() - .map { CalendarFilterEntry(calendarKey(it.source, it.calendarId), it.calendarName.ifBlank { it.source }, it.effectiveColor, it.source) } + .map { CalendarFilterEntry(calendarKey(it.source, it.calendarId), it.calendarName.ifBlank { it.source }, it.effectiveColor, it.source, it.readOnly) } .distinctBy { it.key } .sortedBy { it.name.lowercase() } } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/menu/MenuSheet.kt b/app/src/main/java/com/scarriffle/calendarr/ui/menu/MenuSheet.kt index a95b242..93fc532 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/menu/MenuSheet.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/menu/MenuSheet.kt @@ -13,12 +13,14 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Logout import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.People +import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Sync -import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -31,12 +33,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import com.scarriffle.calendarr.domain.model.Group +import com.scarriffle.calendarr.ui.groups.GroupIcon import com.scarriffle.calendarr.ui.tr @OptIn(ExperimentalMaterial3Api::class) @Composable fun MenuSheet( isAdmin: Boolean, + groups: List, + activeGroup: Group?, + onSwitchGroup: (Group?) -> Unit, onDismiss: () -> Unit, onProfile: () -> Unit, onAppearance: () -> Unit, @@ -64,6 +71,27 @@ fun MenuSheet( MenuRow(Icons.Filled.AccountCircle, tr("menu.profile"), onProfile) MenuRow(Icons.Filled.Palette, tr("menu.appearance"), onAppearance) MenuRow(Icons.Filled.CalendarMonth, tr("menu.accounts"), onAccounts) + // Group view switch (personal + each group) — moved here from the + // top bar to free up space. Group *management* stays below. + if (groups.isNotEmpty()) { + Divider(Modifier.padding(vertical = 4.dp)) + MenuSectionLabel(tr("groups.view")) + GroupSwitchRow( + label = tr("group.switch.personal"), + checked = activeGroup == null, + leading = { Icon(Icons.Filled.Person, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) }, + onClick = { onSwitchGroup(null) }, + ) + groups.forEach { g -> + GroupSwitchRow( + label = g.name, + checked = activeGroup?.id == g.id, + leading = { GroupIcon(g.icon) }, + onClick = { onSwitchGroup(g) }, + ) + } + } + Divider(Modifier.padding(vertical = 4.dp)) MenuRow(Icons.Filled.People, tr("menu.groups"), onGroups) Divider(Modifier.padding(vertical = 4.dp)) MenuRow(Icons.Filled.Sync, tr("menu.sync"), onSync) @@ -85,3 +113,34 @@ private fun MenuRow(icon: ImageVector, label: String, onClick: () -> Unit) { Text(label, style = MaterialTheme.typography.bodyLarge) } } + +@Composable +private fun MenuSectionLabel(text: String) { + Text( + text, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp), + ) +} + +/** A group-view choice (personal or a specific group) with a trailing check. */ +@Composable +private fun GroupSwitchRow( + label: String, + checked: Boolean, + leading: @Composable () -> Unit, + onClick: () -> Unit, +) { + Row( + Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 20.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + leading() + Spacer(Modifier.width(18.dp)) + Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + if (checked) { + Icon(Icons.Filled.Check, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + } + } +}