Compare commits

...

4 Commits

Author SHA1 Message Date
5e470baf4e Merge pull request 'fix(edit): scrollable, grouped, pinned-header calendar picker (#29)' (!56) from fix/calendar-picker-scrollable into release/v2.13.0
Reviewed-on: #56
2026-07-03 13:35:48 +00:00
c8a4f90404 Merge remote-tracking branch 'origin/release/v2.13.0' into fix/calendar-picker-scrollable
All checks were successful
CI / ci (pull_request) Successful in 5m16s
2026-07-03 15:22:38 +02:00
0a89ff9d0e fix(edit): pin the picker title instead of a large collapsing header
Full-screen selection pickers opened with the LargeTopAppBar expanded, so the
title started below the bar and only settled into it after a scroll — a tall
empty header above a short option list. Give CollapsingScaffold a largeTopBar
flag and have FullScreenPicker use a pinned single-line TopAppBar, so every
picker (calendar, theme, week start, language, reminder, agenda range) shows
its title in the bar from the start. Settings and the calendar manager keep
the large collapsing header.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:22:18 +02:00
0718afd5a6 fix(edit): make the new-event calendar picker a scrollable grouped list
The calendar picker was an AlertDialog holding a non-scrolling Column, so
accounts with many calendars overflowed the dialog's fixed height and the
entries past ~9 were unreachable (Codeberg #29).

Replace it with the app's FullScreenPicker (collapsing scaffold + scrollable
content), rendering calendars as connected GroupedRows grouped under their
owning account — the same grouping the visibility filter and calendar manager
use — with a colour chip per row and a check on the current selection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:13:27 +02:00
3 changed files with 110 additions and 47 deletions

View File

@@ -28,6 +28,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -57,10 +58,16 @@ fun positionOf(index: Int, count: Int): Position = when {
} }
/** /**
* The app's standard full-screen list scaffold: a collapsing [LargeTopAppBar] * The app's standard full-screen list scaffold. By default a collapsing
* whose title shrinks into the bar (next to the back button) as the content * [LargeTopAppBar] whose title shrinks into the bar (next to the back button) as
* scrolls. Content is a scrollable column that feeds the toolbar via nested * the content scrolls — used by Settings and the calendar manager, where the
* scroll. Used by Settings and the calendar manager so they share one shell. * large header sets the page. Content is a scrollable column that feeds the
* toolbar via nested scroll.
*
* Set [largeTopBar] to false for a pinned, single-line [TopAppBar] instead: the
* title sits in the bar from the start, with no expanded header to scroll past.
* Preferred for selection pickers, where the tall header is just wasted space
* above the options.
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -68,12 +75,16 @@ fun CollapsingScaffold(
title: String, title: String,
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
largeTopBar: Boolean = true,
snackbarHost: @Composable () -> Unit = {}, snackbarHost: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {}, actions: @Composable RowScope.() -> Unit = {},
content: @Composable ColumnScope.() -> Unit, content: @Composable ColumnScope.() -> Unit,
) { ) {
val scrollBehavior = val scrollBehavior = if (largeTopBar) {
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
} else {
TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
}
Scaffold( Scaffold(
modifier = modifier modifier = modifier
.predictiveBack(onBack = onBack) .predictiveBack(onBack = onBack)
@@ -81,22 +92,34 @@ fun CollapsingScaffold(
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)
.nestedScroll(scrollBehavior.nestedScrollConnection), .nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = { topBar = {
LargeTopAppBar( val navigationIcon = @Composable {
title = { Text(title) },
navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon( Icon(
Icons.AutoMirrored.Filled.ArrowBack, Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.settings_back), contentDescription = stringResource(R.string.settings_back),
) )
} }
}, }
val colors = TopAppBarDefaults.topAppBarColors(
scrolledContainerColor = MaterialTheme.colorScheme.surface,
)
if (largeTopBar) {
LargeTopAppBar(
title = { Text(title) },
navigationIcon = navigationIcon,
actions = actions, actions = actions,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors( colors = colors,
scrolledContainerColor = MaterialTheme.colorScheme.surface,
),
) )
} else {
TopAppBar(
title = { Text(title) },
navigationIcon = navigationIcon,
actions = actions,
scrollBehavior = scrollBehavior,
colors = colors,
)
}
}, },
snackbarHost = snackbarHost, snackbarHost = snackbarHost,
) { innerPadding -> ) { innerPadding ->

View File

@@ -46,8 +46,9 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
/** /**
* Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that * Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that
* reuses the app's [CollapsingScaffold] (collapsing title + back button), so a * reuses the app's [CollapsingScaffold] (back button + full width), but with a
* picker is visually identical to a Settings sub-page and uses the full width. * pinned single-line title rather than the large collapsing header — a picker is
* a short list, so the tall header would only be empty space to scroll past.
* [content] places the connected grouped rows; selecting one calls [onDismiss]. * [content] places the connected grouped rows; selecting one calls [onDismiss].
*/ */
@Composable @Composable
@@ -72,7 +73,12 @@ fun FullScreenPicker(
(view.parent as? DialogWindowProvider)?.window (view.parent as? DialogWindowProvider)?.window
?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING) ?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
} }
CollapsingScaffold(title = title, onBack = onDismiss, content = content) CollapsingScaffold(
title = title,
onBack = onDismiss,
largeTopBar = false,
content = content,
)
} }
} }

View File

@@ -37,6 +37,7 @@ import androidx.compose.material.icons.automirrored.filled.Notes
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Contacts import androidx.compose.material.icons.filled.Contacts
import androidx.compose.material.icons.filled.EventAvailable import androidx.compose.material.icons.filled.EventAvailable
@@ -120,12 +121,15 @@ import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
import de.jeanlucmakiola.calendula.ui.common.predictiveBack import de.jeanlucmakiola.calendula.ui.common.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
import de.jeanlucmakiola.calendula.ui.common.FullScreenPicker
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
import de.jeanlucmakiola.calendula.ui.common.InlineTextField import de.jeanlucmakiola.calendula.ui.common.InlineTextField
import de.jeanlucmakiola.calendula.ui.common.OptionCard import de.jeanlucmakiola.calendula.ui.common.OptionCard
@@ -943,7 +947,7 @@ private fun EventEditContent(
} }
if (showCalendarPicker) { if (showCalendarPicker) {
CalendarPickerDialog( CalendarPicker(
calendars = state.calendars, calendars = state.calendars,
selectedId = form.calendarId, selectedId = form.calendarId,
onSelect = { onSelect = {
@@ -1931,34 +1935,64 @@ private fun ScheduleRow(
} }
} }
/**
* Full-screen calendar picker: a scrollable list of every writable calendar,
* grouped under its owning account like the visibility filter and calendar
* manager. Replaces the former fixed-height [AlertDialog], which clipped the
* list past ~9 rows so accounts with many calendars had unreachable entries
* (#29). Selecting a row calls [onSelect]; the caller closes the picker.
*/
@Composable @Composable
private fun CalendarPickerDialog( private fun CalendarPicker(
calendars: List<CalendarSource>, calendars: List<CalendarSource>,
selectedId: Long?, selectedId: Long?,
onSelect: (Long) -> Unit, onSelect: (Long) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
val dark = isSystemInDarkTheme() // Group by owning account (name, else type, else the calendar's own name),
AlertDialog( // preserving the provider's order within and across groups — the same
onDismissRequest = onDismiss, // grouping [groupByAccount] applies for the drawer filter.
title = { Text(stringResource(R.string.event_detail_calendar)) }, val groups = remember(calendars) {
text = { calendars
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { .groupBy {
calendars.forEach { calendar -> it.accountName.takeIf(String::isNotBlank)
OptionCard( ?: it.accountType.takeIf(String::isNotBlank)
label = calendar.displayName, ?: it.displayName
}
.toList()
}
FullScreenPicker(
title = stringResource(R.string.event_detail_calendar),
onDismiss = onDismiss,
) {
groups.forEach { (account, cals) ->
Text(
text = account,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
)
cals.forEachIndexed { index, calendar ->
val isSelected = calendar.id == selectedId
GroupedRow(
title = calendar.displayName,
position = positionOf(index, cals.size),
selected = isSelected,
leading = { CalendarColorChip(calendar.color) },
trailing = if (isSelected) {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
} else {
null
},
onClick = { onSelect(calendar.id) }, onClick = { onSelect(calendar.id) },
icon = Icons.Default.CalendarMonth,
// The calendar's own colour carries its identity.
iconTint = pastelize(calendar.color, dark),
supportingText = calendar.accountName.takeIf { it.isNotBlank() },
selected = calendar.id == selectedId,
) )
} }
} }
}, }
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
} }