feat: left navigation drawer (calendars + groups + view + menu)

Mirror the iOS side drawer on Android with a ModalNavigationDrawer opened by the
hamburger or an edge-swipe (replaces the calendar filter bottom sheet):
- header with avatar/name/server + manual sync, full menu (gear), and close
- view + group switcher chips
- flat calendar list: tap toggles visibility, long-press opens a context menu
  (hide/banish + reminders), and a Sort toggle reveals up/down reordering.
  Order is device-local (SettingsStore.calendarOrder, mirrors the web).
- month-switching is now a named dropdown (scroll vs pages) like web/iOS
- new device-local 'Hide menu button' setting (drawer opens via edge-swipe)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido Schmit
2026-07-15 18:35:03 +02:00
parent fd804000c5
commit 749eccfaaa
8 changed files with 373 additions and 19 deletions

View File

@@ -159,6 +159,11 @@ class SettingsStore @Inject constructor(
get() = prefs.getBoolean(K_MONTH_PAGED, false) get() = prefs.getBoolean(K_MONTH_PAGED, false)
set(value) = prefs.edit().putBoolean(K_MONTH_PAGED, value).apply() set(value) = prefs.edit().putBoolean(K_MONTH_PAGED, value).apply()
/** Device-local: hide the top-bar menu button (drawer opens via edge-swipe). */
var hideMenuButton: Boolean
get() = prefs.getBoolean(K_HIDE_MENU, false)
set(value) = prefs.edit().putBoolean(K_HIDE_MENU, value).apply()
// --- Hidden calendars ("source:id") --- // --- Hidden calendars ("source:id") ---
var hiddenCalendarKeys: Set<String> var hiddenCalendarKeys: Set<String>
@@ -180,6 +185,18 @@ class SettingsStore @Inject constructor(
get() = prefs.getStringSet(K_REMINDER_DISABLED, emptySet())?.toSet() ?: emptySet() get() = prefs.getStringSet(K_REMINDER_DISABLED, emptySet())?.toSet() ?: emptySet()
set(value) = prefs.edit().putStringSet(K_REMINDER_DISABLED, value).apply() set(value) = prefs.edit().putStringSet(K_REMINDER_DISABLED, value).apply()
// --- Calendar display order ("source:id" keys), device-local (mirrors web cal_order) ---
var calendarOrder: List<String>
get() {
val raw = prefs.getString(K_CAL_ORDER, null) ?: return emptyList()
return runCatching {
val arr = org.json.JSONArray(raw)
(0 until arr.length()).map { arr.getString(it) }
}.getOrDefault(emptyList())
}
set(value) = prefs.edit().putString(K_CAL_ORDER, org.json.JSONArray(value).toString()).apply()
private companion object { private companion object {
const val K_DEFAULT_VIEW = "default_view" const val K_DEFAULT_VIEW = "default_view"
const val K_WEEK_START = "week_start_day" const val K_WEEK_START = "week_start_day"
@@ -201,8 +218,10 @@ class SettingsStore @Inject constructor(
const val K_DEFAULT_DURATION = "default_event_duration_minutes" const val K_DEFAULT_DURATION = "default_event_duration_minutes"
const val K_CACHE_MONTHS = "cache_months" const val K_CACHE_MONTHS = "cache_months"
const val K_MONTH_PAGED = "month_view_paged" const val K_MONTH_PAGED = "month_view_paged"
const val K_HIDE_MENU = "hide_menu_button"
const val K_HIDDEN = "hidden_calendar_keys" const val K_HIDDEN = "hidden_calendar_keys"
const val K_BANISHED = "banished_calendar_keys" const val K_BANISHED = "banished_calendar_keys"
const val K_REMINDER_DISABLED = "reminder_disabled_calendar_keys" const val K_REMINDER_DISABLED = "reminder_disabled_calendar_keys"
const val K_CAL_ORDER = "calendar_order"
} }
} }

View File

@@ -69,6 +69,8 @@ fun CalendarrRoot(vm: MainViewModel = hiltViewModel()) {
onSwitchServer = vm::switchServer, onSwitchServer = vm::switchServer,
onSettingsChanged = vm::applyLocalSettings, onSettingsChanged = vm::applyLocalSettings,
onSettingsSynced = vm::refreshSettings, onSettingsSynced = vm::refreshSettings,
username = vm.username,
serverUrl = vm.serverUrl,
) )
} ?: SplashScreen() } ?: SplashScreen()
} }

View File

@@ -63,6 +63,10 @@ object L10n {
"settings.reset" to "Zurücksetzen", "settings.reset" to "Zurücksetzen",
"settings.appearance" to "Ansicht", "settings.appearance" to "Ansicht",
"settings.device" to "Nur auf diesem Gerät", "settings.device" to "Nur auf diesem Gerät",
"settings.hide_menu_button" to "Menü-Button ausblenden",
"settings.month_mode" to "Monatswechsel",
"settings.month_mode.scroll" to "Fortlaufend scrollen",
"settings.month_mode.paged" to "Seitenweise blättern",
"settings.device.footer" to "Diese Einstellungen gelten nur auf diesem Gerät und werden nicht synchronisiert.", "settings.device.footer" to "Diese Einstellungen gelten nur auf diesem Gerät und werden nicht synchronisiert.",
"settings.directory_hidden" to "Profil verbergen", "settings.directory_hidden" to "Profil verbergen",
"settings.directory_hidden.desc" to "Nicht in der Teilen-/Gruppen-Auswahl anderer Nutzer erscheinen.", "settings.directory_hidden.desc" to "Nicht in der Teilen-/Gruppen-Auswahl anderer Nutzer erscheinen.",
@@ -136,6 +140,8 @@ object L10n {
"accounts.ha.add" to "Home Assistant hinzufügen", "accounts.ha.add" to "Home Assistant hinzufügen",
"filter.title" to "Kalender", "filter.empty" to "Keine Kalender vorhanden", "filter.title" to "Kalender", "filter.empty" to "Keine Kalender vorhanden",
"filter.show_all" to "Alle anzeigen", "filter.hide_all" to "Alle ausblenden", "filter.show_all" to "Alle anzeigen", "filter.hide_all" to "Alle ausblenden",
"filter.sort" to "Sortieren", "filter.done" to "Fertig",
"filter.move_up" to "Nach oben", "filter.move_down" to "Nach unten",
"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",
@@ -236,6 +242,10 @@ object L10n {
"settings.reset" to "Reset", "settings.reset" to "Reset",
"settings.appearance" to "View", "settings.appearance" to "View",
"settings.device" to "This device only", "settings.device" to "This device only",
"settings.hide_menu_button" to "Hide menu button",
"settings.month_mode" to "Month switching",
"settings.month_mode.scroll" to "Continuous scroll",
"settings.month_mode.paged" to "Page by page",
"settings.device.footer" to "These settings apply to this device only and are not synced.", "settings.device.footer" to "These settings apply to this device only and are not synced.",
"settings.directory_hidden" to "Hide profile", "settings.directory_hidden" to "Hide profile",
"settings.directory_hidden.desc" to "Don't appear in other users' share/group pickers.", "settings.directory_hidden.desc" to "Don't appear in other users' share/group pickers.",
@@ -309,6 +319,8 @@ object L10n {
"accounts.ha.add" to "Add Home Assistant", "accounts.ha.add" to "Add Home Assistant",
"filter.title" to "Calendars", "filter.empty" to "No calendars available", "filter.title" to "Calendars", "filter.empty" to "No calendars available",
"filter.show_all" to "Show all", "filter.hide_all" to "Hide all", "filter.show_all" to "Show all", "filter.hide_all" to "Hide all",
"filter.sort" to "Sort", "filter.done" to "Done",
"filter.move_up" to "Move up", "filter.move_down" to "Move down",
"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",

View File

@@ -0,0 +1,280 @@
package com.scarriffle.calendarr.ui.calendar
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
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.Close
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
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.Refresh
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.scarriffle.calendarr.domain.model.CalViewType
import com.scarriffle.calendarr.domain.model.Group
import com.scarriffle.calendarr.ui.groups.GroupIcon
import com.scarriffle.calendarr.ui.tr
import com.scarriffle.calendarr.util.colorFromHex
/**
* The left navigation drawer: calendar visibility (flat, reorderable) + view /
* group switching + a header with manual sync, the full menu, and close. Mirrors
* the iOS side drawer. Order is device-local (CalendarViewModel.calendarOrder).
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun CalendarDrawerContent(
state: CalendarUiState,
vm: CalendarViewModel,
username: String,
serverUrl: String,
calendars: List<CalendarFilterEntry>,
onOpenMenu: () -> Unit,
onSync: () -> Unit,
onClose: () -> Unit,
) {
var isSorting by remember { mutableStateOf(false) }
// Working order: entries sorted by the stored order (unknown → end, by name).
var order by remember(calendars, vm.calendarOrder) {
mutableStateOf(
calendars.sortedWith(
compareBy(
{ vm.calendarOrder.indexOf(it.key).let { i -> if (i < 0) Int.MAX_VALUE else i } },
{ it.name.lowercase() },
)
).map { it.key }
)
}
val entries = order.mapNotNull { key -> calendars.firstOrNull { it.key == key } }
fun move(index: Int, delta: Int) {
val to = index + delta
if (to < 0 || to >= order.size) return
val m = order.toMutableList()
val x = m.removeAt(index); m.add(to, x)
order = m
vm.setCalendarOrder(m)
}
ModalDrawerSheet(Modifier.width(320.dp).fillMaxHeight()) {
Column(Modifier.fillMaxHeight()) {
header(username, serverUrl, onSync, onOpenMenu, onClose)
Divider()
viewSwitcher(state.viewType) { vm.setViewType(it) }
Divider()
if (state.groups.isNotEmpty()) {
groupSwitcher(state.groups, state.activeGroup) { vm.switchGroup(it) }
Divider()
}
Row(
Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(tr("filter.title"), style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.weight(1f))
TextButton(onClick = { isSorting = !isSorting }) {
Text(tr(if (isSorting) "filter.done" else "filter.sort"))
}
}
LazyColumn(Modifier.fillMaxWidth().weight(1f).navigationBarsPadding()) {
items(entries, key = { it.key }) { entry ->
CalendarRow(
entry = entry,
visible = entry.key !in state.hiddenKeys,
reminderDisabled = entry.key in state.reminderDisabledKeys,
sorting = isSorting,
onToggle = { vm.setCalendarHidden(entry.key, !it) },
onBanish = { vm.setCalendarBanished(entry.key, banished = true) },
onToggleReminders = { vm.setCalendarRemindersDisabled(entry.key, disabled = it) },
onMoveUp = { move(order.indexOf(entry.key), -1) },
onMoveDown = { move(order.indexOf(entry.key), 1) },
)
}
}
}
}
}
@Composable
private fun header(
username: String,
serverUrl: String,
onSync: () -> Unit,
onOpenMenu: () -> Unit,
onClose: () -> Unit,
) {
Row(
Modifier.fillMaxWidth().statusBarsPadding().padding(start = 18.dp, end = 8.dp, top = 12.dp, bottom = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier.size(48.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center,
) {
Text(username.firstOrNull()?.uppercase() ?: "?",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onPrimary, fontWeight = FontWeight.Bold)
}
Column(Modifier.weight(1f).padding(start = 14.dp)) {
Text(username, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1)
Text(serverUrl.removePrefix("https://").removePrefix("http://"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
}
IconButton(onClick = onSync) {
Icon(Icons.Filled.Refresh, contentDescription = tr("menu.sync"), tint = MaterialTheme.colorScheme.primary)
}
IconButton(onClick = onOpenMenu) {
Icon(Icons.Filled.Settings, contentDescription = tr("menu.appearance"), tint = MaterialTheme.colorScheme.primary)
}
IconButton(onClick = onClose) {
Icon(Icons.Filled.Close, contentDescription = tr("common.close"), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun viewSwitcher(current: CalViewType, onSelect: (CalViewType) -> Unit) {
Row(
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
CalViewType.entries.forEach { type ->
FilterChip(
selected = current == type,
onClick = { onSelect(type) },
label = { Text(tr("view.${type.key}")) },
leadingIcon = { Icon(type.icon, contentDescription = null, modifier = Modifier.size(18.dp)) },
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun groupSwitcher(groups: List<Group>, active: Group?, onSwitch: (Group?) -> Unit) {
Row(
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
selected = active == null,
onClick = { onSwitch(null) },
label = { Text(tr("group.switch.personal")) },
)
groups.forEach { g ->
FilterChip(
selected = active?.id == g.id,
onClick = { onSwitch(g) },
label = { Text(g.name) },
leadingIcon = { GroupIcon(g.icon) },
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun CalendarRow(
entry: CalendarFilterEntry,
visible: Boolean,
reminderDisabled: Boolean,
sorting: Boolean,
onToggle: (Boolean) -> Unit,
onBanish: () -> Unit,
onToggleReminders: (Boolean) -> Unit,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
) {
var menuOpen by remember { mutableStateOf(false) }
Box {
Row(
Modifier.fillMaxWidth()
.combinedClickable(
onClick = { if (!sorting) onToggle(visible) },
onLongClick = { if (!sorting) menuOpen = true },
)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(14.dp).clip(CircleShape).background(colorFromHex(entry.color)))
Text(
entry.name,
modifier = Modifier.weight(1f).padding(start = 12.dp),
style = MaterialTheme.typography.bodyLarge,
color = if (visible) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
)
if (entry.readOnly) {
Icon(Icons.Filled.Lock, contentDescription = tr("filter.read_only"),
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 6.dp).size(15.dp))
}
if (sorting) {
IconButton(onClick = onMoveUp) { Icon(Icons.Filled.KeyboardArrowUp, contentDescription = tr("filter.move_up")) }
IconButton(onClick = onMoveDown) { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = tr("filter.move_down")) }
} else {
if (reminderDisabled) {
Icon(Icons.Filled.NotificationsOff, contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 6.dp).size(16.dp))
}
Switch(checked = visible, onCheckedChange = onToggle)
}
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
DropdownMenuItem(
text = { Text(tr(if (reminderDisabled) "filter.reminders_on" else "filter.reminders_off")) },
leadingIcon = { Icon(if (reminderDisabled) Icons.Filled.Notifications else Icons.Filled.NotificationsOff, contentDescription = null) },
onClick = { menuOpen = false; onToggleReminders(!reminderDisabled) },
)
DropdownMenuItem(
text = { Text(tr("filter.banish")) },
leadingIcon = { Icon(Icons.Filled.Archive, contentDescription = null) },
onClick = { menuOpen = false; onBanish() },
)
}
}
}

View File

@@ -25,15 +25,17 @@ 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.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.Menu import androidx.compose.material.icons.filled.Menu
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
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Divider import androidx.compose.material3.Divider
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.rememberDrawerState
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
@@ -89,6 +91,8 @@ fun CalendarScreen(
onSwitchServer: () -> Unit, onSwitchServer: () -> Unit,
onSettingsChanged: (AppSettings) -> Unit, onSettingsChanged: (AppSettings) -> Unit,
onSettingsSynced: () -> Unit, onSettingsSynced: () -> Unit,
username: String = "",
serverUrl: String = "",
vm: CalendarViewModel = hiltViewModel(), vm: CalendarViewModel = hiltViewModel(),
) { ) {
val state by vm.state.collectAsState() val state by vm.state.collectAsState()
@@ -128,7 +132,6 @@ fun CalendarScreen(
var viewMenuOpen by remember { mutableStateOf(false) } var viewMenuOpen by remember { mutableStateOf(false) }
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
var showFilter by remember { mutableStateOf(false) }
var detailEvent by remember { mutableStateOf<CalEvent?>(null) } var detailEvent by remember { mutableStateOf<CalEvent?>(null) }
var editor by remember { mutableStateOf<EditorRequest?>(null) } var editor by remember { mutableStateOf<EditorRequest?>(null) }
var overlay by remember { mutableStateOf(Overlay.NONE) } var overlay by remember { mutableStateOf(Overlay.NONE) }
@@ -139,6 +142,14 @@ fun CalendarScreen(
val birthdayScope = rememberCoroutineScope() val birthdayScope = rememberCoroutineScope()
val birthdayCalName = tr("birthday.calendar_name") val birthdayCalName = tr("birthday.calendar_name")
// Side navigation drawer (calendars + groups + view + menu).
val drawerState = rememberDrawerState(DrawerValue.Closed)
val drawerScope = rememberCoroutineScope()
val drawerCalendars = remember(state.events, state.banishedKeys, state.allCalendars) { allKnownCalendars(vm) }
androidx.compose.runtime.LaunchedEffect(drawerState.currentValue) {
if (drawerState.isOpen) vm.loadAllCalendars()
}
// Continuous month scrolling // Continuous month scrolling
val monthListState = rememberLazyListState() val monthListState = rememberLazyListState()
var todaySignal by remember { mutableIntStateOf(0) } var todaySignal by remember { mutableIntStateOf(0) }
@@ -159,6 +170,18 @@ fun CalendarScreen(
if (isMonth) todaySignal++ else vm.moveToToday() if (isMonth) todaySignal++ else vm.moveToToday()
} }
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
CalendarDrawerContent(
state = state, vm = vm, username = username, serverUrl = serverUrl,
calendars = drawerCalendars,
onOpenMenu = { drawerScope.launch { drawerState.close() }; showMenu = true },
onSync = { drawerScope.launch { drawerState.close() }; vm.syncWithServer() },
onClose = { drawerScope.launch { drawerState.close() } },
)
},
) {
Scaffold( Scaffold(
topBar = { topBar = {
CompactTopBar( CompactTopBar(
@@ -166,11 +189,11 @@ fun CalendarScreen(
viewType = state.viewType, viewType = state.viewType,
loading = state.isLoading || state.isBackgroundCaching, loading = state.isLoading || state.isBackgroundCaching,
viewMenuOpen = viewMenuOpen, viewMenuOpen = viewMenuOpen,
onMenu = { showMenu = true }, showMenuButton = !state.hideMenuButton,
onMenu = { drawerScope.launch { drawerState.open() } },
onPrev = { goPrev() }, onPrev = { goPrev() },
onToday = { goToday() }, onToday = { goToday() },
onNext = { goNext() }, onNext = { goNext() },
onFilter = { showFilter = true },
onViewMenuToggle = { viewMenuOpen = it }, onViewMenuToggle = { viewMenuOpen = it },
onSelectView = { vm.setViewType(it) }, onSelectView = { vm.setViewType(it) },
) )
@@ -242,6 +265,7 @@ fun CalendarScreen(
} }
} }
} }
}
// ---- Sheets ---- // ---- Sheets ----
@@ -262,15 +286,6 @@ fun CalendarScreen(
) )
} }
if (showFilter) {
androidx.compose.runtime.LaunchedEffect(Unit) { vm.loadAllCalendars() }
CalendarFilterSheet(
events = remember(state.events, state.banishedKeys, state.allCalendars) { allKnownCalendars(vm) },
vm = vm,
onDismiss = { showFilter = false },
)
}
dayPreview?.let { date -> dayPreview?.let { date ->
DayPreviewDialog( DayPreviewDialog(
date = date, date = date,
@@ -450,11 +465,11 @@ private fun CompactTopBar(
viewType: CalViewType, viewType: CalViewType,
loading: Boolean, loading: Boolean,
viewMenuOpen: Boolean, viewMenuOpen: Boolean,
showMenuButton: Boolean,
onMenu: () -> Unit, onMenu: () -> Unit,
onPrev: () -> Unit, onPrev: () -> Unit,
onToday: () -> Unit, onToday: () -> Unit,
onNext: () -> Unit, onNext: () -> Unit,
onFilter: () -> Unit,
onViewMenuToggle: (Boolean) -> Unit, onViewMenuToggle: (Boolean) -> Unit,
onSelectView: (CalViewType) -> Unit, onSelectView: (CalViewType) -> Unit,
) { ) {
@@ -487,7 +502,6 @@ private fun CompactTopBar(
strokeWidth = 2.dp, strokeWidth = 2.dp,
) )
} }
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"))
DropdownMenu(expanded = viewMenuOpen, onDismissRequest = { onViewMenuToggle(false) }) { DropdownMenu(expanded = viewMenuOpen, onDismissRequest = { onViewMenuToggle(false) }) {
@@ -500,7 +514,7 @@ private fun CompactTopBar(
} }
} }
} }
CompactIcon(Icons.Filled.Menu, onMenu, tr("nav.menu")) if (showMenuButton) CompactIcon(Icons.Filled.Menu, onMenu, tr("nav.menu"))
} }
Divider( Divider(
thickness = 0.5.dp, thickness = 0.5.dp,

View File

@@ -62,6 +62,8 @@ data class CalendarUiState(
val allCalendars: List<CalendarFilterEntry> = emptyList(), val allCalendars: List<CalendarFilterEntry> = emptyList(),
// Device-local: month view as horizontal paged (swipe) vs. scroll feed. // Device-local: month view as horizontal paged (swipe) vs. scroll feed.
val monthViewPaged: Boolean = false, val monthViewPaged: Boolean = false,
// Device-local: hide the top-bar menu button (drawer opens via edge-swipe).
val hideMenuButton: Boolean = false,
) )
fun groupMemberKey(ownerId: Int): String = "gm:$ownerId" fun groupMemberKey(ownerId: Int): String = "gm:$ownerId"
@@ -127,14 +129,22 @@ class CalendarViewModel @Inject constructor(
banishedKeys = settingsStore.banishedCalendarKeys, banishedKeys = settingsStore.banishedCalendarKeys,
reminderDisabledKeys = settingsStore.reminderDisabledCalendarKeys, reminderDisabledKeys = settingsStore.reminderDisabledCalendarKeys,
monthViewPaged = settingsStore.monthViewPaged, monthViewPaged = settingsStore.monthViewPaged,
hideMenuButton = settingsStore.hideMenuButton,
) )
} }
/** Re-read the device-local month-view mode (called when settings close). */ /** Re-read the device-local view prefs (called when settings close). */
fun refreshMonthViewMode() { fun refreshMonthViewMode() {
_state.update { it.copy(monthViewPaged = settingsStore.monthViewPaged) } _state.update { it.copy(
monthViewPaged = settingsStore.monthViewPaged,
hideMenuButton = settingsStore.hideMenuButton,
) }
} }
// Device-local calendar order ("source:id"), mirrors the web cal_order.
val calendarOrder: List<String> get() = settingsStore.calendarOrder
fun setCalendarOrder(keys: List<String>) { settingsStore.calendarOrder = keys }
/** Default duration (minutes) for a new event's end time. */ /** Default duration (minutes) for a new event's end time. */
val defaultEventDurationMinutes: Int get() = settingsStore.loadSettings().defaultEventDurationMinutes val defaultEventDurationMinutes: Int get() = settingsStore.loadSettings().defaultEventDurationMinutes

View File

@@ -147,7 +147,13 @@ fun SettingsScreen(
synced("week_start_day"), { toggle("week_start_day") }, synced("week_start_day"), { toggle("week_start_day") },
) )
SyncSwitchRow(tr("settings.dimpast"), settings.dimPastEvents, { update(settings.copy(dimPastEvents = it)) }, synced("dim_past_events"), { toggle("dim_past_events") }) SyncSwitchRow(tr("settings.dimpast"), settings.dimPastEvents, { update(settings.copy(dimPastEvents = it)) }, synced("dim_past_events"), { toggle("dim_past_events") })
SyncSwitchRow(tr("settings.month_paged"), settings.monthViewPaged, { update(settings.copy(monthViewPaged = it)) }, synced("month_view_paged"), { toggle("month_view_paged") }) SyncDropdownRow(
tr("settings.month_mode"),
listOf("false" to tr("settings.month_mode.scroll"), "true" to tr("settings.month_mode.paged")),
settings.monthViewPaged.toString(),
{ update(settings.copy(monthViewPaged = it.toBoolean())) },
synced("month_view_paged"), { toggle("month_view_paged") },
)
SyncDropdownRow( SyncDropdownRow(
tr("settings.hourheight"), tr("settings.hourheight"),
listOf("28" to tr("settings.hourheight.compact"), "44" to tr("settings.hourheight.normal"), "60" to tr("settings.hourheight.comfort"), "80" to tr("settings.hourheight.large")), listOf("28" to tr("settings.hourheight.compact"), "44" to tr("settings.hourheight.normal"), "60" to tr("settings.hourheight.comfort"), "80" to tr("settings.hourheight.large")),
@@ -196,6 +202,12 @@ fun SettingsScreen(
listOf("1" to tr("settings.linecontrast.barely"), "2" to tr("settings.linecontrast.subtle"), "3" to tr("settings.linecontrast.normal"), "4" to tr("settings.linecontrast.strong")), listOf("1" to tr("settings.linecontrast.barely"), "2" to tr("settings.linecontrast.subtle"), "3" to tr("settings.linecontrast.normal"), "4" to tr("settings.linecontrast.strong")),
settings.lineContrast.toString(), settings.lineContrast.toString(),
) { update(settings.copy(lineContrast = it.toInt())) } ) { update(settings.copy(lineContrast = it.toInt())) }
var hideMenu by remember { mutableStateOf(vm.hideMenuButton) }
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.size(44.dp))
Text(tr("settings.hide_menu_button"), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
Switch(checked = hideMenu, onCheckedChange = { hideMenu = it; vm.hideMenuButton = it })
}
Text(tr("settings.device.footer"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 44.dp, top = 6.dp)) Text(tr("settings.device.footer"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 44.dp, top = 6.dp))
Spacer(Modifier.size(40.dp)) Spacer(Modifier.size(40.dp))
} }

View File

@@ -28,6 +28,11 @@ class SettingsViewModel @Inject constructor(
} }
} }
/** Device-local: hide the top-bar menu button (drawer opens via edge-swipe). */
var hideMenuButton: Boolean
get() = settingsStore.hideMenuButton
set(value) { settingsStore.hideMenuButton = value }
// ---- Per-setting sync flags ---- // ---- Per-setting sync flags ----
val syncableKeys: List<String> get() = settingsStore.syncableKeys val syncableKeys: List<String> get() = settingsStore.syncableKeys