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:
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user