fix(agenda): localize dates and refine the range bar
Localize every agenda date via a new localizedDateFormatter helper that lays a field skeleton out in the locale's own order (Android best-pattern), instead of a hardcoded day-month-year layout: the range-window summary, the screen's day headers, and the widget's day headers. This also fixes the window mixing two orders (e.g. "15 Jul – Aug 13, 2026"). Refine the range bar: the banner drops the range name (it already sits on the selector button beside it) and shows just the concrete dates; the selector keeps a subtle neutral surface tint — distinct from the top-bar view switcher's secondary container so the two don't compete — and its right edge lines up with the switcher. The anchored empty-today card takes a single event row's resting corner radius. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,9 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import java.time.YearMonth
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
@@ -87,10 +86,12 @@ fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when
|
||||
|
||||
/**
|
||||
* The concrete span the range covers, starting at [start] through [end]
|
||||
* (inclusive), for a human-readable header:
|
||||
* - [AgendaRange.Day] → a single medium date ("27 Jun 2026")
|
||||
* (inclusive), for a human-readable header. Both ends use the same day-month
|
||||
* order so a span never mixes "15 Jul" with "Aug 13, 2026":
|
||||
* - [AgendaRange.Day] → a single date ("27 Jun 2026")
|
||||
* - [AgendaRange.ThisMonth] → month and year ("June 2026")
|
||||
* - everything else → "start – end" ("27 Jun – 3 Jul 2026")
|
||||
* - everything else → "start – end" ("27 Jun – 3 Jul 2026"), with the start's
|
||||
* year shown too only when it differs from the end's.
|
||||
*/
|
||||
fun agendaRangeWindowSummary(
|
||||
range: AgendaRange,
|
||||
@@ -100,11 +101,15 @@ fun agendaRangeWindowSummary(
|
||||
): String {
|
||||
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day)
|
||||
val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day)
|
||||
val medium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
val dayMonth = localizedDateFormatter(locale, "dMMM")
|
||||
val dayMonthYear = localizedDateFormatter(locale, "dMMMy")
|
||||
return when (range) {
|
||||
AgendaRange.Day -> medium.format(javaStart)
|
||||
AgendaRange.ThisMonth -> DateTimeFormatter.ofPattern("LLLL yyyy", locale).format(javaStart)
|
||||
else -> "${DateTimeFormatter.ofPattern("d MMM", locale).format(javaStart)} – ${medium.format(javaEnd)}"
|
||||
AgendaRange.Day -> dayMonthYear.format(javaStart)
|
||||
AgendaRange.ThisMonth -> localizedDateFormatter(locale, "LLLLy").format(javaStart)
|
||||
else -> {
|
||||
val startFmt = if (start.year == end.year) dayMonth else dayMonthYear
|
||||
"${startFmt.format(javaStart)} – ${dayMonthYear.format(javaEnd)}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,17 +15,17 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Coffee
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -66,6 +66,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
@@ -84,7 +85,6 @@ import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
@@ -172,9 +172,11 @@ fun AgendaScreen(
|
||||
successState?.takeIf { it.showRangeBar }?.let { s ->
|
||||
Row(
|
||||
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
|
||||
.fillMaxWidth()
|
||||
.padding(start = 28.dp, end = 16.dp, top = 8.dp, bottom = 8.dp),
|
||||
.padding(start = 28.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
|
||||
) {
|
||||
AgendaRangeBanner(
|
||||
range = s.range,
|
||||
@@ -216,9 +218,11 @@ fun AgendaScreen(
|
||||
}
|
||||
|
||||
/**
|
||||
* A compact tonal pill showing the agenda's current range. Tapping it opens the
|
||||
* range picker as a session-only override. Filled with the primary container
|
||||
* while an override is active, so the temporary state is obvious.
|
||||
* The agenda's current range, tapped to open the range picker as a session-only
|
||||
* override. Shares the top-bar view switcher's button shape so the two read as a
|
||||
* family, but stays low-emphasis — a subtle neutral surface tint rather than the
|
||||
* switcher's secondary container, so it doesn't compete. Fills with the primary
|
||||
* container only while an override is active, to make that temporary state clear.
|
||||
*/
|
||||
@Composable
|
||||
private fun AgendaRangePill(
|
||||
@@ -227,43 +231,34 @@ private fun AgendaRangePill(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val container = if (isOverride) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
}
|
||||
val content = if (isOverride) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Surface(
|
||||
color = container,
|
||||
contentColor = content,
|
||||
shape = RoundedCornerShape(50),
|
||||
modifier = modifier.clickable(onClick = onClick),
|
||||
FilledTonalButton(
|
||||
onClick = onClick,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = if (isOverride) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
},
|
||||
contentColor = if (isOverride) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.DateRange,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = agendaRangeLabel(range),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = agendaRangeLabel(range),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A header naming the concrete window currently shown, e.g. "Showing all events
|
||||
* for · Today, 27 Jun 2026" / "This week, 27 Jun – 3 Jul" / "This month, June 2026".
|
||||
* 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
|
||||
* lives on the selector button beside it, so it isn't repeated here.
|
||||
*/
|
||||
@Composable
|
||||
private fun AgendaRangeBanner(
|
||||
@@ -280,8 +275,10 @@ private fun AgendaRangeBanner(
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// Just the concrete dates — the range's name ("Next 30 days") already
|
||||
// sits on the selector button to the right, so repeating it here is noise.
|
||||
Text(
|
||||
text = "${agendaRangeLabel(range)}, $window",
|
||||
text = window,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
@@ -418,6 +415,8 @@ private fun AgendaDayHeader(
|
||||
@Composable
|
||||
private fun AgendaEmptyDayRow(onClick: () -> Unit) {
|
||||
Card(
|
||||
// Match a single event row's resting corner radius (floret groupedShape).
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
@@ -575,7 +574,7 @@ private fun formatTime(instant: Instant, is24Hour: Boolean, locale: Locale): Str
|
||||
private fun formatAgendaDate(date: LocalDate): String {
|
||||
val locale = Locale.getDefault()
|
||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
return "$weekday, ${date.day}. $monthName ${date.year}"
|
||||
// Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026"
|
||||
// vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout.
|
||||
return localizedDateFormatter(locale, "EEEdMMMy").format(java)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.core.os.ConfigurationCompat
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
@@ -18,3 +19,15 @@ fun currentLocale(): Locale {
|
||||
ConfigurationCompat.getLocales(configuration).get(0) ?: Locale.getDefault()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [DateTimeFormatter] for [skeleton]'s fields laid out in [locale]'s own order
|
||||
* (via Android's best-pattern matching), so dates read naturally per locale
|
||||
* instead of a hardcoded day-month-year order. [skeleton] lists the wanted
|
||||
* fields, e.g. "dMMMy" (day, abbreviated month, year) or "EEEdMMM" (weekday too).
|
||||
*/
|
||||
fun localizedDateFormatter(locale: Locale, skeleton: String): DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern(
|
||||
android.text.format.DateFormat.getBestDateTimePattern(locale, skeleton),
|
||||
locale,
|
||||
)
|
||||
|
||||
@@ -55,6 +55,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||
@@ -67,7 +68,6 @@ import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
@@ -399,9 +399,9 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
|
||||
}
|
||||
val locale = Locale.getDefault()
|
||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
val formatted = "$weekday, ${date.day} $monthName"
|
||||
// Weekday + date in the locale's own field order (compact: no year), rather
|
||||
// than a hardcoded day-month layout.
|
||||
val formatted = localizedDateFormatter(locale, "EEEdMMM").format(java)
|
||||
return if (relative != null) "$relative · $formatted" else formatted
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user