feat: clearer shared calendars + move group switch into the menu

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 <noreply@anthropic.com>
This commit is contained in:
Guido Schmit
2026-07-04 18:52:57 +02:00
parent e9d58d8239
commit 993d5d83e1
6 changed files with 87 additions and 61 deletions

View File

@@ -270,7 +270,12 @@ class CalendarRepository @Inject constructor(
/** Resolve all calendars the user can create events in. */ /** Resolve all calendars the user can create events in. */
suspend fun getWritableCalendars(): List<WritableCalendar> = withContext(Dispatchers.IO) { suspend fun getWritableCalendars(): List<WritableCalendar> = withContext(Dispatchers.IO) {
val result = mutableListOf<WritableCalendar>() val result = mutableListOf<WritableCalendar>()
runCatching { api.getLocalCalendars() }.getOrDefault(emptyList()).forEach { cal -> 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) result += WritableCalendar("local-${cal.id}", cal.name, cal.color, "local", cal.id)
} }
runCatching { api.getCalDAVAccounts() }.getOrDefault(emptyList()) runCatching { api.getCalDAVAccounts() }.getOrDefault(emptyList())

View File

@@ -36,6 +36,8 @@ data class CalEvent(
val displayTitle: String? = null, val displayTitle: String? = null,
// Reminder offsets in minutes-before-start (0 = at start). Local events only. // Reminder offsets in minutes-before-start (0 = at start). Local events only.
val reminders: List<Int> = emptyList(), val reminders: List<Int> = 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 * Group view supplies a server-resolved colour (display_color); otherwise
@@ -126,6 +128,7 @@ data class CalEvent(
reminders = json.optJSONArray("reminders")?.let { arr -> reminders = json.optJSONArray("reminders")?.let { arr ->
(0 until arr.length()).mapNotNull { (arr.opt(it) as? Number)?.toInt() } (0 until arr.length()).mapNotNull { (arr.opt(it) as? Number)?.toInt() }
} ?: emptyList(), } ?: emptyList(),
readOnly = json.optBoolean("read_only", false),
) )
} }
} }

View File

@@ -124,6 +124,7 @@ object L10n {
"filter.button" to "Kalender ein-/ausblenden", "filter.button" to "Kalender ein-/ausblenden",
"filter.sync_error" to "Synchronisierung fehlgeschlagen", "filter.sync_error" to "Synchronisierung fehlgeschlagen",
"filter.banish" to "Dauerhaft ausblenden", "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.", "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_header" to "Ausgeblendete Kalender",
"accounts.banished_unhide" to "Wieder einblenden", "accounts.banished_unhide" to "Wieder einblenden",
@@ -268,6 +269,7 @@ object L10n {
"filter.button" to "Show/hide calendars", "filter.button" to "Show/hide calendars",
"filter.sync_error" to "Sync failed", "filter.sync_error" to "Sync failed",
"filter.banish" to "Hide permanently", "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.", "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_header" to "Hidden calendars",
"accounts.banished_unhide" to "Show again", "accounts.banished_unhide" to "Show again",

View File

@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Archive 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.Notifications
import androidx.compose.material.icons.filled.NotificationsOff import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.material.icons.filled.WarningAmber 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.ui.tr
import com.scarriffle.calendarr.util.colorFromHex 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -109,6 +110,14 @@ fun CalendarFilterSheet(
modifier = Modifier.weight(1f).padding(start = 12.dp), modifier = Modifier.weight(1f).padding(start = 12.dp),
style = MaterialTheme.typography.bodyLarge, 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) { if (!groupMode) {
val remDisabled = entry.key in state.reminderDisabledKeys val remDisabled = entry.key in state.reminderDisabledKeys
IconButton(onClick = { vm.setCalendarRemindersDisabled(entry.key, disabled = !remDisabled) }) { IconButton(onClick = { vm.setCalendarRemindersDisabled(entry.key, disabled = !remDisabled) }) {

View File

@@ -18,12 +18,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add 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.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Today import androidx.compose.material.icons.filled.Today
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
@@ -156,9 +154,6 @@ fun CalendarScreen(
viewType = state.viewType, viewType = state.viewType,
loading = state.isLoading || state.isBackgroundCaching, loading = state.isLoading || state.isBackgroundCaching,
viewMenuOpen = viewMenuOpen, viewMenuOpen = viewMenuOpen,
groups = state.groups,
activeGroup = state.activeGroup,
onSwitchGroup = { vm.switchGroup(it) },
onMenu = { showMenu = true }, onMenu = { showMenu = true },
onPrev = { goPrev() }, onPrev = { goPrev() },
onToday = { goToday() }, onToday = { goToday() },
@@ -213,6 +208,9 @@ fun CalendarScreen(
if (showMenu) { if (showMenu) {
MenuSheet( MenuSheet(
isAdmin = false, isAdmin = false,
groups = state.groups,
activeGroup = state.activeGroup,
onSwitchGroup = { showMenu = false; vm.switchGroup(it) },
onDismiss = { showMenu = false }, onDismiss = { showMenu = false },
onProfile = { showMenu = false; overlay = Overlay.PROFILE }, onProfile = { showMenu = false; overlay = Overlay.PROFILE },
onAppearance = { showMenu = false; overlay = Overlay.SETTINGS }, onAppearance = { showMenu = false; overlay = Overlay.SETTINGS },
@@ -398,9 +396,6 @@ private fun CompactTopBar(
viewType: CalViewType, viewType: CalViewType,
loading: Boolean, loading: Boolean,
viewMenuOpen: Boolean, viewMenuOpen: Boolean,
groups: List<Group>,
activeGroup: Group?,
onSwitchGroup: (Group?) -> Unit,
onMenu: () -> Unit, onMenu: () -> Unit,
onPrev: () -> Unit, onPrev: () -> Unit,
onToday: () -> Unit, onToday: () -> Unit,
@@ -437,9 +432,6 @@ private fun CompactTopBar(
strokeWidth = 2.dp, strokeWidth = 2.dp,
) )
} }
if (groups.isNotEmpty()) {
GroupSwitcher(groups = groups, activeGroup = activeGroup, onSwitchGroup = onSwitchGroup)
}
CompactIcon(Icons.Filled.FilterList, onFilter, tr("filter.button")) CompactIcon(Icons.Filled.FilterList, onFilter, tr("filter.button"))
Box { Box {
CompactIcon(viewType.icon, { onViewMenuToggle(true) }, tr("view.change")) 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<Group>, 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 @Composable
private fun GroupBanner(group: Group, onExit: () -> Unit) { private fun GroupBanner(group: Group, onExit: () -> Unit) {
Surface(color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f), modifier = Modifier.fillMaxWidth()) { 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. */ * can be toggled back on. */
private fun allKnownCalendars(vm: CalendarViewModel): List<CalendarFilterEntry> { private fun allKnownCalendars(vm: CalendarViewModel): List<CalendarFilterEntry> {
return vm.knownCalendars() 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 } .distinctBy { it.key }
.sortedBy { it.name.lowercase() } .sortedBy { it.name.lowercase() }
} }

View File

@@ -13,12 +13,14 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle 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.Dns
import androidx.compose.material.icons.filled.Logout import androidx.compose.material.icons.filled.Logout
import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.People 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.Sync
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material3.Divider import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon 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.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp 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 import com.scarriffle.calendarr.ui.tr
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MenuSheet( fun MenuSheet(
isAdmin: Boolean, isAdmin: Boolean,
groups: List<Group>,
activeGroup: Group?,
onSwitchGroup: (Group?) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onProfile: () -> Unit, onProfile: () -> Unit,
onAppearance: () -> Unit, onAppearance: () -> Unit,
@@ -64,6 +71,27 @@ fun MenuSheet(
MenuRow(Icons.Filled.AccountCircle, tr("menu.profile"), onProfile) MenuRow(Icons.Filled.AccountCircle, tr("menu.profile"), onProfile)
MenuRow(Icons.Filled.Palette, tr("menu.appearance"), onAppearance) MenuRow(Icons.Filled.Palette, tr("menu.appearance"), onAppearance)
MenuRow(Icons.Filled.CalendarMonth, tr("menu.accounts"), onAccounts) 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) MenuRow(Icons.Filled.People, tr("menu.groups"), onGroups)
Divider(Modifier.padding(vertical = 4.dp)) Divider(Modifier.padding(vertical = 4.dp))
MenuRow(Icons.Filled.Sync, tr("menu.sync"), onSync) 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) 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)
}
}
}