Tidy the app bar at larger display and font scales (#165) (#261)

### Part A — the title shortens instead of wrapping

`CalendarTitleButton` measures the full title against the width the bar hands
the title slot (`BoxWithConstraints` + `rememberTextMeasurer`) and falls back to
a shorter form when it does not fit. One pass, so no flicker, and it adapts on
its own to locale, font scale and to the pill being absent after #150.

- Month and week fall back to the three-letter month ("Sep", "Mär").
- Day drops the weekday instead ("Wed, 2 Sep 2027" → "2 Sep 2027") — there is no
  shorter month form left there, `EEEdMMM` is already abbreviated.
- Either way the title now clamps to one line and ellipsises rather than growing
  the bar to two lines.

The choice itself is a pure `titleFor(title, shortTitle, titleWidth,
availableWidth)`, so it is unit-tested rather than only reachable through a
composable. `formatCalendarTitle` gained no new behaviour, so the widget header
is untouched.

### Part B — the trailing edge, not the whole rhythm

`AppBarSpacing` holds the shared values: the side inset (also consumed by the
month grid, so the pill lines up with the content under it), the title's start
inset, and the two trailing insets — one measured to a container's background,
one to an icon button's glyph.

**Deviation from the issue:** the issue asks for one edge inset at *both* ends
and one gap between adjacent controls. Only the trailing edge and the title
inset are shared here. M3 derives both the title's position and the gap between
two adjacent action icons from the icon buttons' width, so putting the leading
edge and the inter-icon gaps on the same rhythm meant narrowing the icon buttons
from 48.dp to 32.dp — that reads cramped and shrinks the touch targets. The
leading edge and the icon gaps stay on M3's defaults. The title's 8.dp start
inset stays real padding rather than a negative offset, so the menu button's hit
box is intact.

The agenda range bar picks its end inset from whether the view switcher is
showing: aligned to the pill's background when it is, to the search icon's glyph
once #150 hides it.

Closes #165

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/261
This commit is contained in:
Jean-Luc Makiola
2026-09-02 15:25:38 +02:00
parent 7e8119be02
commit 03287ae25d
10 changed files with 228 additions and 38 deletions

View File

@@ -13,6 +13,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
the title now simply runs to the edge of its chip — a few more letters per the title now simply runs to the edge of its chip — a few more letters per
cell, which is often the difference between two events you can tell apart and cell, which is often the difference between two events you can tell apart and
two you can't ([#164]). two you can't ([#164]).
- The calendar titles now shorten instead of being cut off. When the full month
name doesn't fit the top bar, the month and week views fall back to its
three-letter form and the day view drops the weekday, rather than trailing off
mid-word at large font sizes. The view switcher, the title and the agenda's
range bar also line up with the grid underneath them ([#165]).
## [2.19.1] — 2026-08-11 ## [2.19.1] — 2026-08-11
@@ -1422,3 +1427,4 @@ automatically, with zero telemetry and no internet permission.
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163 [#163]: https://codeberg.org/jlmakiola/calendula/issues/163
[#173]: https://codeberg.org/jlmakiola/calendula/issues/173 [#173]: https://codeberg.org/jlmakiola/calendula/issues/173
[#164]: https://codeberg.org/jlmakiola/calendula/issues/164 [#164]: https://codeberg.org/jlmakiola/calendula/issues/164
[#165]: https://codeberg.org/jlmakiola/calendula/issues/165

View File

@@ -43,6 +43,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -51,6 +52,8 @@ import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.hasEnded import de.jeanlucmakiola.calendula.domain.hasEnded
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.floret.identity.animateItemMotion import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
@@ -160,13 +163,24 @@ fun AgendaScreen(
// One bar at the top: the "showing …" header on the left and the // One bar at the top: the "showing …" header on the left and the
// session range switcher on the right (one settings toggle). // session range switcher on the right (one settings toggle).
successState?.takeIf { it.showRangeBar }?.let { s -> successState?.takeIf { it.showRangeBar }?.let { s ->
// end lines the selector up with whatever ends the top bar:
// the view switcher's background, or — once #150 hides it —
// the search icon's glyph.
val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) {
AppBarSpacing.Inset
} else {
AppBarSpacing.IconTrailingInset
}
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
// end aligns the selector's right edge with the top-bar view
// switcher (its 8.dp margin + the app bar's 4.dp inset).
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 28.dp, end = 12.dp, top = 8.dp, bottom = 8.dp), .padding(
start = RANGE_BAR_TEXT_INSET,
end = selectorEnd,
top = 8.dp,
bottom = 8.dp,
),
) { ) {
AgendaRangeBanner( AgendaRangeBanner(
range = s.range, range = s.range,
@@ -246,6 +260,9 @@ private fun AgendaRangePill(
} }
} }
/** Optical start inset for the range bar's text, set against the title above it. */
private val RANGE_BAR_TEXT_INSET = 28.dp
/** /**
* A header naming the concrete window currently shown under a "showing …" label, * A header naming the concrete window currently shown under a "showing …" label,
* e.g. "27 Jun 2026" / "27 Jun 3 Jul 2026" / "June 2026". The range's name * e.g. "27 Jun 2026" / "27 Jun 3 Jul 2026" / "June 2026". The range's name
@@ -419,9 +436,14 @@ private fun AgendaTopBar(
) { ) {
TopAppBar( TopAppBar(
title = { title = {
// A plain label rather than a CalendarTitleButton, so it takes that
// one's inset and one-line clamp itself.
Text( Text(
text = stringResource(R.string.view_agenda), text = stringResource(R.string.view_agenda),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = AppBarSpacing.TitleInset),
) )
}, },
navigationIcon = { navigationIcon = {
@@ -444,7 +466,6 @@ private fun AgendaTopBar(
current = selectedView, current = selectedView,
cycle = quickSwitchViews, cycle = quickSwitchViews,
onCycle = onCycleView, onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
) )
}, },
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(

View File

@@ -0,0 +1,40 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.ui.unit.dp
/**
* Trailing-edge and title spacing shared by the four calendar app bars (#165).
*
* Only the trailing edge and the title's start are shared; the leading edge and
* the gaps between the action icons stay on M3's defaults, which derive both
* from the icon buttons' width.
*/
object AppBarSpacing {
/** Side inset shared by the bars' trailing edge and the month grid. */
val Inset = 8.dp
/**
* Start inset on the title, taking it clear of the navigation icon M3 places
* it flush against. Real padding rather than a negative offset, which would
* swallow taps meant for the menu button.
*/
val TitleInset = 8.dp
/** M3's own padding around the actions row. */
internal val BarPadding = 4.dp
private val IconButtonSize = 48.dp
private val IconSize = 24.dp
/**
* End padding for a container-backed control that ends the bar, measured to
* its background. Coerced because [androidx.compose.foundation.layout.padding]
* throws on a negative value.
*/
val ContainerTrailingInset = (Inset - BarPadding).coerceAtLeast(0.dp)
/** Screen edge to the glyph of an icon button that ends the bar. */
val IconTrailingInset = BarPadding + (IconButtonSize - IconSize) / 2
}

View File

@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -12,6 +13,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -19,6 +21,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
@@ -31,10 +35,9 @@ import kotlinx.datetime.LocalDate
* [currentDate] seeds the picker with whatever the bar is currently naming (the * [currentDate] seeds the picker with whatever the bar is currently naming (the
* visible day, week start or month anchor). * visible day, week start or month anchor).
* *
* The row keeps its own 8.dp inset rather than shifting back onto the app bar's * [shortTitle] replaces [title] when the full one does not fit the width the app
* start alignment: M3 places the title flush against the navigation icon's * bar hands the title slot (#165). Either way the line clamps to one and
* trailing edge, and the title is hit-tested on top of it, so a negative offset * ellipsises, so pass a [shortTitle] wherever there is something left to drop.
* would swallow taps meant for the menu button.
*/ */
@Composable @Composable
fun CalendarTitleButton( fun CalendarTitleButton(
@@ -42,6 +45,7 @@ fun CalendarTitleButton(
currentDate: LocalDate, currentDate: LocalDate,
onJumpToDate: (LocalDate) -> Unit, onJumpToDate: (LocalDate) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
shortTitle: String = title,
) { ) {
var showDatePicker by rememberSaveable { mutableStateOf(false) } var showDatePicker by rememberSaveable { mutableStateOf(false) }
@@ -53,14 +57,27 @@ fun CalendarTitleButton(
onClickLabel = stringResource(R.string.drawer_jump_to_date), onClickLabel = stringResource(R.string.drawer_jump_to_date),
role = Role.Button, role = Role.Button,
) { showDatePicker = true } ) { showDatePicker = true }
.padding(horizontal = 8.dp), .padding(horizontal = AppBarSpacing.TitleInset),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( val style = MaterialTheme.typography.titleLarge
text = title, BoxWithConstraints(modifier = Modifier.weight(1f, fill = false)) {
style = MaterialTheme.typography.titleLarge, val measurer = rememberTextMeasurer()
modifier = Modifier.weight(1f, fill = false), val shown = remember(title, shortTitle, style, constraints.maxWidth, measurer) {
titleFor(
title = title,
shortTitle = shortTitle,
titleWidth = measurer.measure(title, style).size.width,
availableWidth = constraints.maxWidth,
) )
}
Text(
text = shown,
style = style,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Icon( Icon(
imageVector = Icons.Default.ArrowDropDown, imageVector = Icons.Default.ArrowDropDown,
contentDescription = null, contentDescription = null,
@@ -79,3 +96,11 @@ fun CalendarTitleButton(
) )
} }
} }
/** Falls back to [shortTitle] when [title] is wider than [availableWidth] (#165). */
internal fun titleFor(
title: String,
shortTitle: String,
titleWidth: Int,
availableWidth: Int,
): String = if (titleWidth <= availableWidth) title else shortTitle

View File

@@ -1,11 +1,13 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
/** /**
* Top-bar pill that shows the current view and cycles to the next one on tap * Top-bar pill that shows the current view and cycles to the next one on tap
@@ -14,6 +16,10 @@ import androidx.compose.ui.res.stringResource
* Renders nothing when [cycle] holds fewer than [QuickSwitchConfig.MIN_CYCLE] * Renders nothing when [cycle] holds fewer than [QuickSwitchConfig.MIN_CYCLE]
* views (#150), so it drops straight into an app bar's `actions` slot without a * views (#150), so it drops straight into an app bar's `actions` slot without a
* wrapping condition — the same way [TodayAction] handles being turned off. * wrapping condition — the same way [TodayAction] handles being turned off.
*
* [trailingInset] lands the pill's background on the bar's own rhythm; pass
* `0.dp` outside a top app bar, where M3's actions padding is not there to
* cancel (#165).
*/ */
@Composable @Composable
fun ViewSwitcherPill( fun ViewSwitcherPill(
@@ -21,12 +27,13 @@ fun ViewSwitcherPill(
cycle: List<CalendarView>, cycle: List<CalendarView>,
onCycle: () -> Unit, onCycle: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
trailingInset: Dp = AppBarSpacing.ContainerTrailingInset,
) { ) {
if (cycle.size < QuickSwitchConfig.MIN_CYCLE) return if (cycle.size < QuickSwitchConfig.MIN_CYCLE) return
FilledTonalButton( FilledTonalButton(
onClick = onCycle, onClick = onCycle,
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
modifier = modifier, modifier = modifier.padding(end = trailingInset),
) { ) {
Text(stringResource(current.labelRes)) Text(stringResource(current.labelRes))
} }

View File

@@ -421,12 +421,17 @@ private fun DayTopBar(
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior, scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) { ) {
val locale = currentLocale() val locale = currentLocale()
val (title, shortTitle) = remember(date, locale, currentYear) {
formatDayTitle(date, locale, currentYear) to
formatDayTitle(date, locale, currentYear, abbreviated = true)
}
TopAppBar( TopAppBar(
title = { title = {
CalendarTitleButton( CalendarTitleButton(
title = formatDayTitle(date, locale, currentYear), title = title,
currentDate = date, currentDate = date,
onJumpToDate = onJumpToDate, onJumpToDate = onJumpToDate,
shortTitle = shortTitle,
) )
}, },
navigationIcon = { navigationIcon = {
@@ -449,7 +454,6 @@ private fun DayTopBar(
current = selectedView, current = selectedView,
cycle = quickSwitchViews, cycle = quickSwitchViews,
onCycle = onCycleView, onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
) )
}, },
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
@@ -815,10 +819,16 @@ private fun DayLoading() {
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String = private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
formatMinuteOfDay(min, is24Hour, locale) formatMinuteOfDay(min, is24Hour, locale)
private fun formatDayTitle(date: LocalDate, locale: Locale, currentYear: Int): String = /** [abbreviated] drops the weekday, leaving the date itself (#165). */
private fun formatDayTitle(
date: LocalDate,
locale: Locale,
currentYear: Int,
abbreviated: Boolean = false,
): String =
formatCalendarTitle( formatCalendarTitle(
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day), date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day),
locale = locale, locale = locale,
currentYear = currentYear, currentYear = currentYear,
skeleton = "EEEdMMM", skeleton = if (abbreviated) "dMMM" else "EEEdMMM",
) )

View File

@@ -131,6 +131,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaEmptyDayRow
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.TodayAction import de.jeanlucmakiola.calendula.ui.common.TodayAction
@@ -292,10 +293,14 @@ fun MonthScreen(
// carries the year instead of repeating it two lines further down. Dense has // carries the year instead of repeating it two lines further down. Dense has
// no header to defer to, so it keeps the full title. // no header to defer to, so it keeps the full title.
val locale = currentLocale() val locale = currentLocale()
val topBarTitle = if (viewStyle == MonthViewStyle.Continuous) { val (topBarTitle, topBarShortTitle) = remember(viewStyle, titleMonth, locale, today.year) {
titleMonth.year.toString() if (viewStyle == MonthViewStyle.Continuous) {
val year = titleMonth.year.toString()
year to year
} else { } else {
formatMonthTitle(titleMonth, locale, currentYear = today.year) formatMonthTitle(titleMonth, locale, currentYear = today.year) to
formatMonthTitle(titleMonth, locale, currentYear = today.year, abbreviated = true)
}
} }
// Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide). // Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide).
@@ -384,6 +389,7 @@ fun MonthScreen(
topBar = { topBar = {
MonthTopBar( MonthTopBar(
title = topBarTitle, title = topBarTitle,
shortTitle = topBarShortTitle,
titleDate = LocalDate(titleMonth.year, titleMonth.month, 1), titleDate = LocalDate(titleMonth.year, titleMonth.month, 1),
selectedView = selectedView, selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
@@ -649,6 +655,7 @@ private fun ContinuousMonthContent(
@Composable @Composable
private fun MonthTopBar( private fun MonthTopBar(
title: String, title: String,
shortTitle: String,
titleDate: LocalDate, titleDate: LocalDate,
selectedView: CalendarView, selectedView: CalendarView,
onCycleView: () -> Unit, onCycleView: () -> Unit,
@@ -666,6 +673,7 @@ private fun MonthTopBar(
title = title, title = title,
currentDate = titleDate, currentDate = titleDate,
onJumpToDate = onJumpToDate, onJumpToDate = onJumpToDate,
shortTitle = shortTitle,
) )
}, },
navigationIcon = { navigationIcon = {
@@ -688,7 +696,6 @@ private fun MonthTopBar(
current = selectedView, current = selectedView,
cycle = quickSwitchViews, cycle = quickSwitchViews,
onCycle = onCycleView, onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
) )
}, },
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
@@ -706,7 +713,7 @@ internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp), .padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
) { ) {
// Reserve the gutter so the weekday labels stay over their day columns. // Reserve the gutter so the weekday labels stay over their day columns.
if (showWeekNumbers) Spacer(Modifier.width(WEEK_NUMBER_GUTTER)) if (showWeekNumbers) Spacer(Modifier.width(WEEK_NUMBER_GUTTER))
@@ -769,10 +776,10 @@ internal fun MonthGrid(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
// Match the weekday header's 8dp inset so day cells sit under their // Match the weekday header's inset so day cells sit under their
// labels, and so the week-number gutter's centre lines up with the // labels, and so the week-number gutter's centre lines up with the
// top bar's hamburger (4dp bar inset + 24dp half icon button). // top bar's hamburger (4dp bar inset + 24dp half icon button).
.padding(horizontal = 8.dp, vertical = 4.dp), .padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
val month = state.month val month = state.month
@@ -864,7 +871,7 @@ private fun ContinuousMonthBlock(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp) .padding(horizontal = AppBarSpacing.Inset)
.padding(bottom = CONTINUOUS_MONTH_GAP), .padding(bottom = CONTINUOUS_MONTH_GAP),
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
@@ -946,7 +953,7 @@ internal fun DenseMonthGrid(
state = listState, state = listState,
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 8.dp), .padding(horizontal = AppBarSpacing.Inset),
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
// Bottom inset clears the FAB stack so the last row stays tappable. // Bottom inset clears the FAB stack so the last row stays tappable.
contentPadding = PaddingValues(top = 4.dp, bottom = 96.dp), contentPadding = PaddingValues(top = 4.dp, bottom = 96.dp),
@@ -1406,7 +1413,7 @@ internal fun SplitMonthGrid(
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp), .padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
state.weeks.forEach { week -> state.weeks.forEach { week ->
@@ -1771,7 +1778,7 @@ private fun ContinuousMonthSkeleton(dense: Boolean) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 8.dp) .padding(horizontal = AppBarSpacing.Inset)
.clipToBounds(), .clipToBounds(),
) { ) {
if (!dense) { if (!dense) {
@@ -2419,7 +2426,7 @@ private fun MonthGridLoading() {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 8.dp, vertical = 4.dp), .padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
repeat(6) { repeat(6) {
@@ -2445,10 +2452,16 @@ private fun MonthGridLoading() {
} }
} }
private fun formatMonthTitle(ym: YearMonth, locale: Locale, currentYear: Int): String = /** [abbreviated] swaps the full month name for its three-letter form (#165). */
private fun formatMonthTitle(
ym: YearMonth,
locale: Locale,
currentYear: Int,
abbreviated: Boolean = false,
): String =
formatCalendarTitle( formatCalendarTitle(
date = java.time.LocalDate.of(ym.year, ym.month.ordinal + 1, 1), date = java.time.LocalDate.of(ym.year, ym.month.ordinal + 1, 1),
locale = locale, locale = locale,
currentYear = currentYear, currentYear = currentYear,
skeleton = "LLLL", skeleton = if (abbreviated) "LLL" else "LLLL",
) )

View File

@@ -456,12 +456,17 @@ private fun WeekTopBar(
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior, scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) { ) {
val locale = currentLocale() val locale = currentLocale()
val (title, shortTitle) = remember(weekStart, locale, currentYear) {
formatWeekTitle(weekStart, locale, currentYear) to
formatWeekTitle(weekStart, locale, currentYear, abbreviated = true)
}
TopAppBar( TopAppBar(
title = { title = {
CalendarTitleButton( CalendarTitleButton(
title = formatWeekTitle(weekStart, locale, currentYear), title = title,
currentDate = weekStart, currentDate = weekStart,
onJumpToDate = onJumpToDate, onJumpToDate = onJumpToDate,
shortTitle = shortTitle,
) )
}, },
navigationIcon = { navigationIcon = {
@@ -484,7 +489,6 @@ private fun WeekTopBar(
current = selectedView, current = selectedView,
cycle = quickSwitchViews, cycle = quickSwitchViews,
onCycle = onCycleView, onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
) )
}, },
// Match the static top section exactly: plain surface, lifting to // Match the static top section exactly: plain surface, lifting to
@@ -1019,13 +1023,18 @@ private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): Stri
* "December" with no year would be actively misleading while the reader is * "December" with no year would be actively misleading while the reader is
* looking straight at January dates. * looking straight at January dates.
*/ */
private fun formatWeekTitle(weekStart: LocalDate, locale: Locale, currentYear: Int): String { private fun formatWeekTitle(
weekStart: LocalDate,
locale: Locale,
currentYear: Int,
abbreviated: Boolean = false,
): String {
val weekEnd = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY) val weekEnd = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
return formatCalendarTitle( return formatCalendarTitle(
date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1), date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1),
locale = locale, locale = locale,
currentYear = currentYear, currentYear = currentYear,
skeleton = "LLLL", skeleton = if (abbreviated) "LLL" else "LLLL",
forceYear = weekEnd.year != weekStart.year, forceYear = weekEnd.year != weekStart.year,
) )
} }

View File

@@ -0,0 +1,28 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class AppBarSpacingTest {
@Test
fun `the pill's background ends one inset from the screen edge`() {
// The padding is applied inside M3's actions row, so it carries that
// row's own inset on top of whatever the pill asks for (#165).
val rendered = AppBarSpacing.BarPadding + AppBarSpacing.ContainerTrailingInset
assertThat(rendered).isEqualTo(AppBarSpacing.Inset)
}
@Test
fun `trailing insets stay non-negative`() {
// Modifier.padding throws on a negative value.
assertThat(AppBarSpacing.ContainerTrailingInset.value).isAtLeast(0f)
assertThat(AppBarSpacing.IconTrailingInset.value).isAtLeast(0f)
}
@Test
fun `an icon button's glyph ends further in than a container's edge`() {
assertThat(AppBarSpacing.IconTrailingInset.value)
.isGreaterThan(AppBarSpacing.Inset.value)
}
}

View File

@@ -0,0 +1,31 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class CalendarTitleButtonTest {
@Test
fun `keeps the full title when it fits`() {
assertThat(titleFor("September", "Sep", titleWidth = 200, availableWidth = 300))
.isEqualTo("September")
}
@Test
fun `keeps the full title when it fills the slot exactly`() {
assertThat(titleFor("September", "Sep", titleWidth = 300, availableWidth = 300))
.isEqualTo("September")
}
@Test
fun `falls back to the short title when the full one overflows`() {
assertThat(titleFor("September", "Sep", titleWidth = 400, availableWidth = 300))
.isEqualTo("Sep")
}
@Test
fun `a caller with nothing to shorten still gets its own title back`() {
assertThat(titleFor("Wed, 2 Sep", "Wed, 2 Sep", titleWidth = 400, availableWidth = 300))
.isEqualTo("Wed, 2 Sep")
}
}