fix(android): edge-to-edge insets + show all calendars in the filter

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 <noreply@anthropic.com>
This commit is contained in:
Guido Schmit
2026-07-07 22:29:07 +02:00
parent bd3c92aa86
commit 8d7ac124ae
8 changed files with 98 additions and 13 deletions

View File

@@ -3,6 +3,7 @@ package com.scarriffle.calendarr
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.scarriffle.calendarr.ui.CalendarrRoot import com.scarriffle.calendarr.ui.CalendarrRoot
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@@ -13,6 +14,10 @@ class MainActivity : ComponentActivity() {
// Covers the window from the first frame (incl. warm-start), then hands // 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. // off to the in-app branded splash which stays until data is loaded.
installSplashScreen() 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) super.onCreate(savedInstanceState)
setContent { setContent {
CalendarrRoot() CalendarrRoot()

View File

@@ -1,6 +1,8 @@
package com.scarriffle.calendarr.ui package com.scarriffle.calendarr.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -48,12 +50,18 @@ fun CalendarrRoot(vm: MainViewModel = hiltViewModel()) {
} }
when (route) { when (route) {
AppRoute.SETUP -> ServerSetupScreen(onConfigured = vm::onServerConfigured) // Auth screens draw full-screen; inset them from the system
AppRoute.LOGIN -> LoginScreen( // bars (edge-to-edge) so their content isn't under status/nav.
serverUrl = vm.serverUrl, AppRoute.SETUP -> Box(Modifier.systemBarsPadding()) {
onLoggedIn = vm::onLoggedIn, ServerSetupScreen(onConfigured = vm::onServerConfigured)
onBack = vm::switchServer, }
) AppRoute.LOGIN -> Box(Modifier.systemBarsPadding()) {
LoginScreen(
serverUrl = vm.serverUrl,
onLoggedIn = vm::onLoggedIn,
onBack = vm::switchServer,
)
}
AppRoute.MAIN -> calendarVm?.let { cvm -> AppRoute.MAIN -> calendarVm?.let { cvm ->
CalendarScreen( CalendarScreen(
vm = cvm, vm = cvm,

View File

@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -430,7 +431,7 @@ private fun SharingSheet(vm: AccountsViewModel, calendarId: Int, onDismiss: () -
ModalBottomSheet(onDismissRequest = onDismiss) { ModalBottomSheet(onDismissRequest = onDismiss) {
Column( 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) Text(tr("share.title"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.size(16.dp)) Spacer(Modifier.size(16.dp))

View File

@@ -6,8 +6,11 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.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
@@ -60,7 +63,12 @@ fun CalendarFilterSheet(
val hiddenSet = if (groupMode) state.hiddenGroupKeys else state.hiddenKeys val hiddenSet = if (groupMode) state.hiddenGroupKeys else state.hiddenKeys
ModalBottomSheet(onDismissRequest = onDismiss) { 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( Row(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,

View File

@@ -11,11 +11,14 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
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.ChevronLeft import androidx.compose.material.icons.filled.ChevronLeft
@@ -169,12 +172,17 @@ fun CalendarScreen(
shape = CircleShape, shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary, containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary, contentColor = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.navigationBarsPadding(),
) { ) {
Icon(Icons.Filled.Add, contentDescription = tr("cal.new_event")) 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 -> ) { padding ->
Column(Modifier.fillMaxSize().padding(padding)) { Column(Modifier.fillMaxSize().padding(padding).navigationBarsPadding()) {
state.error?.let { err -> state.error?.let { err ->
ErrorBanner(err, onRetry = { vm.loadVisible(force = true) }, onDismiss = vm::clearError) ErrorBanner(err, onRetry = { vm.loadVisible(force = true) }, onDismiss = vm::clearError)
} }
@@ -223,8 +231,9 @@ fun CalendarScreen(
} }
if (showFilter) { if (showFilter) {
androidx.compose.runtime.LaunchedEffect(Unit) { vm.loadAllCalendars() }
CalendarFilterSheet( CalendarFilterSheet(
events = remember(state.events, state.banishedKeys) { allKnownCalendars(vm) }, events = remember(state.events, state.banishedKeys, state.allCalendars) { allKnownCalendars(vm) },
vm = vm, vm = vm,
onDismiss = { showFilter = false }, onDismiss = { showFilter = false },
) )
@@ -405,8 +414,9 @@ private fun CompactTopBar(
onSelectView: (CalViewType) -> Unit, onSelectView: (CalViewType) -> Unit,
) { ) {
val twoLine = viewType == CalViewType.WEEK || viewType == CalViewType.DAY 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) { Surface(color = MaterialTheme.colorScheme.background) {
Column { Column(Modifier.statusBarsPadding()) {
Row( Row(
Modifier.fillMaxWidth().height(50.dp).padding(horizontal = 2.dp), Modifier.fillMaxWidth().height(50.dp).padding(horizontal = 2.dp),
verticalAlignment = Alignment.CenterVertically, 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 * cache (minus banished) so a locally quick-hidden calendar still shows up and
* 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() val fromEvents = vm.knownCalendars()
.map { CalendarFilterEntry(calendarKey(it.source, it.calendarId), it.calendarName.ifBlank { it.source }, it.effectiveColor, it.source, it.readOnly) } .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 } .distinctBy { it.key }
.sortedBy { it.name.lowercase() } .sortedBy { it.name.lowercase() }
} }

View File

@@ -55,6 +55,9 @@ data class CalendarUiState(
// hidden keys ("gm:<userId>" / "gc"). In-memory; reset when switching group. // hidden keys ("gm:<userId>" / "gc"). In-memory; reset when switching group.
val activeGroupMembers: List<GroupMember> = emptyList(), val activeGroupMembers: List<GroupMember> = emptyList(),
val hiddenGroupKeys: Set<String> = emptySet(), val hiddenGroupKeys: Set<String> = 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<CalendarFilterEntry> = emptyList(),
) )
fun groupMemberKey(ownerId: Int): String = "gm:$ownerId" fun groupMemberKey(ownerId: Int): String = "gm:$ownerId"
@@ -449,6 +452,47 @@ class CalendarViewModel @Inject constructor(
.filter { calendarKey(it.source, it.calendarId) !in banished } .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<CalendarFilterEntry>()
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 * Banish ("permanently hide") a calendar, or lift the banish. Unlike the
* quick-hide, this DOES sync to the server (`sidebar_hidden`/`enabled`) for * quick-hide, this DOES sync to the server (`sidebar_hidden`/`enabled`) for

View File

@@ -11,6 +11,8 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@@ -149,6 +151,8 @@ fun EventEditorSheet(
Column( Column(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.navigationBarsPadding()
.imePadding()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp) .padding(horizontal = 20.dp)
.padding(bottom = 32.dp), .padding(bottom = 32.dp),

View File

@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -232,7 +233,7 @@ private fun GroupEditSheet(
} }
ModalBottomSheet(onDismissRequest = onDismiss) { 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( Text(
if (existing == null) tr("groups.create") else tr("groups.manage"), if (existing == null) tr("groups.create") else tr("groups.manage"),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,