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:
@@ -270,9 +270,14 @@ class CalendarRepository @Inject constructor(
|
||||
/** Resolve all calendars the user can create events in. */
|
||||
suspend fun getWritableCalendars(): List<WritableCalendar> = withContext(Dispatchers.IO) {
|
||||
val result = mutableListOf<WritableCalendar>()
|
||||
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 ->
|
||||
|
||||
@@ -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<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
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) }) {
|
||||
|
||||
@@ -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<Group>,
|
||||
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<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
|
||||
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<CalendarFilterEntry> {
|
||||
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() }
|
||||
}
|
||||
|
||||
@@ -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<Group>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user