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 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()

View File

@@ -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,

View File

@@ -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))

View File

@@ -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,

View File

@@ -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<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) }
// 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() }
}

View File

@@ -55,6 +55,9 @@ data class CalendarUiState(
// hidden keys ("gm:<userId>" / "gc"). In-memory; reset when switching group.
val activeGroupMembers: List<GroupMember> = emptyList(),
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"
@@ -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<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
* 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.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),

View File

@@ -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,