From 8d7ac124ae6691e4915860e9f89b5e00d928b3ab Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Tue, 7 Jul 2026 22:29:07 +0200 Subject: [PATCH] fix(android): edge-to-edge insets + show all calendars in the filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) Burger menu (and other bottom sheets) had rows hidden behind the system navigation bar: MainActivity never called enableEdgeToEdge(), so the sheets' navigationBarsPadding resolved to 0. Enable edge-to-edge and place the insets ourselves — top bar statusBarsPadding, content/FAB navigationBarsPadding, auth screens systemBarsPadding — and add navigationBarsPadding (+ imePadding for the editor) to the Filter/Groups/Accounts/Editor sheets. 2) The calendar filter only listed calendars that had events in the loaded range, so empty calendars couldn't be toggled. Load the full calendar list from all sources (loadAllCalendars) and merge it with the event-derived entries — matching iOS, which already shows the complete list. Co-Authored-By: Claude Opus 4.8 --- .../com/scarriffle/calendarr/MainActivity.kt | 5 +++ .../scarriffle/calendarr/ui/CalendarrRoot.kt | 20 ++++++--- .../calendarr/ui/accounts/AccountsScreen.kt | 3 +- .../ui/calendar/CalendarFilterSheet.kt | 10 ++++- .../calendarr/ui/calendar/CalendarScreen.kt | 22 ++++++++-- .../ui/calendar/CalendarViewModel.kt | 44 +++++++++++++++++++ .../calendarr/ui/event/EventEditorSheet.kt | 4 ++ .../calendarr/ui/groups/GroupsScreen.kt | 3 +- 8 files changed, 98 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt b/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt index 8af7e9e..54ee865 100644 --- a/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt +++ b/app/src/main/java/com/scarriffle/calendarr/MainActivity.kt @@ -3,6 +3,7 @@ package com.scarriffle.calendarr import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import com.scarriffle.calendarr.ui.CalendarrRoot import dagger.hilt.android.AndroidEntryPoint @@ -13,6 +14,10 @@ class MainActivity : ComponentActivity() { // Covers the window from the first frame (incl. warm-start), then hands // off to the in-app branded splash which stays until data is loaded. installSplashScreen() + // Draw edge-to-edge so system-bar insets reach every window, including + // ModalBottomSheets — otherwise navigationBarsPadding() inside the sheets + // resolves to 0 and the bottom menu rows hide behind the nav bar. + enableEdgeToEdge() super.onCreate(savedInstanceState) setContent { CalendarrRoot() diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt b/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt index 75ff9b6..4ef00b3 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt @@ -1,6 +1,8 @@ package com.scarriffle.calendarr.ui +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable @@ -48,12 +50,18 @@ fun CalendarrRoot(vm: MainViewModel = hiltViewModel()) { } when (route) { - AppRoute.SETUP -> ServerSetupScreen(onConfigured = vm::onServerConfigured) - AppRoute.LOGIN -> LoginScreen( - serverUrl = vm.serverUrl, - onLoggedIn = vm::onLoggedIn, - onBack = vm::switchServer, - ) + // Auth screens draw full-screen; inset them from the system + // bars (edge-to-edge) so their content isn't under status/nav. + AppRoute.SETUP -> Box(Modifier.systemBarsPadding()) { + ServerSetupScreen(onConfigured = vm::onServerConfigured) + } + AppRoute.LOGIN -> Box(Modifier.systemBarsPadding()) { + LoginScreen( + serverUrl = vm.serverUrl, + onLoggedIn = vm::onLoggedIn, + onBack = vm::switchServer, + ) + } AppRoute.MAIN -> calendarVm?.let { cvm -> CalendarScreen( vm = cvm, diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsScreen.kt index 554ddbb..66f905a 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/accounts/AccountsScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize 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.lazy.LazyColumn @@ -430,7 +431,7 @@ private fun SharingSheet(vm: AccountsViewModel, calendarId: Int, onDismiss: () - ModalBottomSheet(onDismissRequest = onDismiss) { Column( - Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 24.dp).verticalScroll(rememberScrollState()), + Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 20.dp).padding(bottom = 24.dp).verticalScroll(rememberScrollState()), ) { Text(tr("share.title"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) Spacer(Modifier.size(16.dp)) 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 2959ca4..167da4b 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 @@ -6,8 +6,11 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row 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.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Archive @@ -60,7 +63,12 @@ fun CalendarFilterSheet( val hiddenSet = if (groupMode) state.hiddenGroupKeys else state.hiddenKeys ModalBottomSheet(onDismissRequest = onDismiss) { - Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { + Column( + Modifier.fillMaxWidth() + .verticalScroll(rememberScrollState()) + .navigationBarsPadding() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, 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 b968596..dd79b8f 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 @@ -11,11 +11,14 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +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.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ChevronLeft @@ -169,12 +172,17 @@ fun CalendarScreen( shape = CircleShape, containerColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.navigationBarsPadding(), ) { Icon(Icons.Filled.Add, contentDescription = tr("cal.new_event")) } }, + // Edge-to-edge: we place the system-bar insets ourselves (top bar gets + // statusBarsPadding, the content column gets navigationBarsPadding) so + // nothing hides behind the status/nav bars. + contentWindowInsets = WindowInsets(0), ) { padding -> - Column(Modifier.fillMaxSize().padding(padding)) { + Column(Modifier.fillMaxSize().padding(padding).navigationBarsPadding()) { state.error?.let { err -> ErrorBanner(err, onRetry = { vm.loadVisible(force = true) }, onDismiss = vm::clearError) } @@ -223,8 +231,9 @@ fun CalendarScreen( } if (showFilter) { + androidx.compose.runtime.LaunchedEffect(Unit) { vm.loadAllCalendars() } CalendarFilterSheet( - events = remember(state.events, state.banishedKeys) { allKnownCalendars(vm) }, + events = remember(state.events, state.banishedKeys, state.allCalendars) { allKnownCalendars(vm) }, vm = vm, onDismiss = { showFilter = false }, ) @@ -405,8 +414,9 @@ private fun CompactTopBar( onSelectView: (CalViewType) -> Unit, ) { val twoLine = viewType == CalViewType.WEEK || viewType == CalViewType.DAY + // Surface fills behind the status bar; the content column is inset below it. Surface(color = MaterialTheme.colorScheme.background) { - Column { + Column(Modifier.statusBarsPadding()) { Row( Modifier.fillMaxWidth().height(50.dp).padding(horizontal = 2.dp), verticalAlignment = Alignment.CenterVertically, @@ -490,8 +500,12 @@ private fun GroupBanner(group: Group, onExit: () -> Unit) { * cache (minus banished) so a locally quick-hidden calendar still shows up and * can be toggled back on. */ private fun allKnownCalendars(vm: CalendarViewModel): List { - return vm.knownCalendars() + val fromEvents = vm.knownCalendars() .map { CalendarFilterEntry(calendarKey(it.source, it.calendarId), it.calendarName.ifBlank { it.source }, it.effectiveColor, it.source, it.readOnly) } + // Event-derived entries first (current server colour / owner name), then the + // full source list so calendars WITHOUT events in range still appear; + // distinctBy keeps the event-derived entry when a calendar has both. + return (fromEvents + vm.state.value.allCalendars) .distinctBy { it.key } .sortedBy { it.name.lowercase() } } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt index 3df4b2c..15b5ea7 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt @@ -55,6 +55,9 @@ data class CalendarUiState( // hidden keys ("gm:" / "gc"). In-memory; reset when switching group. val activeGroupMembers: List = emptyList(), val hiddenGroupKeys: Set = emptySet(), + // Full calendar list across all sources (loaded on demand) so the filter + // shows every calendar, including ones with no events in the loaded range. + val allCalendars: List = emptyList(), ) fun groupMemberKey(ownerId: Int): String = "gm:$ownerId" @@ -449,6 +452,47 @@ class CalendarViewModel @Inject constructor( .filter { calendarKey(it.source, it.calendarId) !in banished } } + /** + * Load the full calendar list across all sources into [CalendarUiState.allCalendars] + * so the filter shows every calendar, even ones with no events in the loaded + * range. Called when the filter sheet opens. Read-only flag for shared local + * calendars from owned/permission; banished calendars are dropped. + */ + fun loadAllCalendars() { + viewModelScope.launch { + val banished = _state.value.banishedKeys + val entries = mutableListOf() + runCatching { repository.getLocalCalendars() }.getOrDefault(emptyList()).forEach { c -> + entries += CalendarFilterEntry( + key = calendarKey("local", c.id.toString()), + name = if (c.owned) c.name else (c.sharedBy ?: c.name), + color = c.color, + source = "local", + readOnly = !c.owned && c.permission != "read_write", + ) + } + runCatching { repository.getCalDAVAccounts() }.getOrDefault(emptyList()).forEach { acc -> + acc.calendars.orEmpty().forEach { c -> + entries += CalendarFilterEntry(calendarKey("caldav", c.id.toString()), c.name, c.color ?: acc.color, "caldav") + } + } + runCatching { repository.getGoogleAccounts() }.getOrDefault(emptyList()).forEach { acc -> + acc.calendars.orEmpty().forEach { c -> + entries += CalendarFilterEntry(calendarKey("google", c.id.toString()), c.name, c.color ?: "#4285f4", "google") + } + } + runCatching { repository.getHomeAssistantAccounts() }.getOrDefault(emptyList()).forEach { acc -> + acc.calendars.orEmpty().forEach { c -> + entries += CalendarFilterEntry(calendarKey("homeassistant", c.id.toString()), c.name, c.color ?: "#46bdc6", "homeassistant") + } + } + runCatching { repository.getICalSubscriptions() }.getOrDefault(emptyList()).forEach { s -> + entries += CalendarFilterEntry(calendarKey("ical", s.id.toString()), s.name, s.color, "ical") + } + _state.update { st -> st.copy(allCalendars = entries.filter { it.key !in banished }) } + } + } + /** * Banish ("permanently hide") a calendar, or lift the banish. Unlike the * quick-hide, this DOES sync to the server (`sidebar_hidden`/`enabled`) for diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/event/EventEditorSheet.kt b/app/src/main/java/com/scarriffle/calendarr/ui/event/EventEditorSheet.kt index 61021d5..73cf958 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/event/EventEditorSheet.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/event/EventEditorSheet.kt @@ -11,6 +11,8 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -149,6 +151,8 @@ fun EventEditorSheet( Column( Modifier .fillMaxWidth() + .navigationBarsPadding() + .imePadding() .verticalScroll(rememberScrollState()) .padding(horizontal = 20.dp) .padding(bottom = 32.dp), diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/groups/GroupsScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/groups/GroupsScreen.kt index d57c822..92cfb05 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/groups/GroupsScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/groups/GroupsScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize 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.lazy.LazyColumn @@ -232,7 +233,7 @@ private fun GroupEditSheet( } ModalBottomSheet(onDismissRequest = onDismiss) { - Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 24.dp).verticalScroll(rememberScrollState())) { + Column(Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 20.dp).padding(bottom = 24.dp).verticalScroll(rememberScrollState())) { Text( if (existing == null) tr("groups.create") else tr("groups.manage"), style = MaterialTheme.typography.titleLarge,