fix(widget): scale the agenda widget with its size (#51)
The "Upcoming" agenda widget used Glance's default SizeMode.Single: it was composed once at the minimum size and the launcher stretched that single RemoteViews when enlarged, so the text stayed small-widget-sized no matter how big the widget grew. Reported as a "font size" request (#51), but it's really a missing size-response. Switch to SizeMode.Exact (like MonthWidget) and read LocalSize.current to pick one of four tiers (COMPACT/REGULAR/LARGE/XLARGE), scaling type and row metrics. Exact over Responsive so the ~30-day LazyColumn isn't replicated per tier. Width picks the tier, height can only lower it. Width governs how much of a title fits on a row, so it's what should drive type size; height only decides how many rows are visible, so a tall narrow widget shows more events rather than bigger text. Height does act as a cap, though, or a squashed widget would keep the large type its width earned in a sliver of space. Thresholds are spread over the width range a phone actually produces (measured on a Pixel/Nova: a compact widget is 222dp wide, a large one 378dp) rather than a theoretical range, so the tiers are reachable in practice; XLARGE is reserved for tablets/foldables. COMPACT reproduces the original constants verbatim, so an existing widget is visually unchanged. The tier logic lives in a pure, Glance-free AgendaScale.kt (compose.ui.unit only) and is JVM-tested: the COMPACT baseline, the width buckets, the height cap stepping a squashed widget down, and that height never raises the tier. No new setting: the widget follows the size the launcher/user already chose. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The "Upcoming" agenda widget now scales its text and rows to the size you give
|
||||||
|
it. Previously it was laid out once for the smallest size and simply stretched
|
||||||
|
when enlarged, so the text stayed small no matter how big you made the widget.
|
||||||
|
Now a bigger widget gets bigger, more readable type and roomier rows, while the
|
||||||
|
default size looks exactly as before — no new setting; it follows the size you
|
||||||
|
already chose ([#51]).
|
||||||
|
|
||||||
## [2.16.0] — 2026-07-17
|
## [2.16.0] — 2026-07-17
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -1038,5 +1046,6 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#47]: https://codeberg.org/jlmakiola/calendula/issues/47
|
[#47]: https://codeberg.org/jlmakiola/calendula/issues/47
|
||||||
[#48]: https://codeberg.org/jlmakiola/calendula/issues/48
|
[#48]: https://codeberg.org/jlmakiola/calendula/issues/48
|
||||||
[#49]: https://codeberg.org/jlmakiola/calendula/issues/49
|
[#49]: https://codeberg.org/jlmakiola/calendula/issues/49
|
||||||
|
[#51]: https://codeberg.org/jlmakiola/calendula/issues/51
|
||||||
[#52]: https://codeberg.org/jlmakiola/calendula/issues/52
|
[#52]: https://codeberg.org/jlmakiola/calendula/issues/52
|
||||||
[#60]: https://codeberg.org/jlmakiola/calendula/issues/60
|
[#60]: https://codeberg.org/jlmakiola/calendula/issues/60
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.agenda
|
||||||
|
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.TextUnit
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Size tiers the agenda widget scales its typography and row metrics across (#51).
|
||||||
|
*
|
||||||
|
* The widget uses [androidx.glance.appwidget.SizeMode.Exact], so the composition
|
||||||
|
* sees the widget's live size via `LocalSize.current`; [scaleFor] buckets that
|
||||||
|
* into one of these tiers and [metricsFor] returns the sizes to draw with. Kept
|
||||||
|
* in a pure, Glance-free file (only `compose.ui.unit`) so the bucketing and the
|
||||||
|
* "default size is unchanged" invariant are covered by plain JVM tests.
|
||||||
|
*/
|
||||||
|
internal enum class AgendaScale { COMPACT, REGULAR, LARGE, XLARGE }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every size the agenda widget varies by tier. Values that don't need to grow
|
||||||
|
* (horizontal paddings, the 5.dp stripe width, corner radii) stay inline literals
|
||||||
|
* in the widget rather than routing through here.
|
||||||
|
*/
|
||||||
|
internal data class AgendaMetrics(
|
||||||
|
val title: TextUnit, // header "Upcoming"
|
||||||
|
val dayHeader: TextUnit, // day label ("Today · …")
|
||||||
|
val eventTitle: TextUnit, // event title line
|
||||||
|
val eventTime: TextUnit, // time/location line
|
||||||
|
val placeholder: TextUnit, // "no more events today" (#35)
|
||||||
|
val message: TextUnit, // empty/permission centred message
|
||||||
|
val stripeH: Dp, // coloured event stripe height
|
||||||
|
val iconImage: Dp, // header action icon glyph
|
||||||
|
val iconBox: Dp, // header action touch target
|
||||||
|
val rowVPad: Dp, // event row vertical padding
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [AgendaMetrics] per tier. [AgendaScale.COMPACT] reproduces the widget's original
|
||||||
|
* hardcoded constants **verbatim** — so at the default/small size nothing changes;
|
||||||
|
* a JVM test pins this against the baseline. Larger tiers scale up by roughly
|
||||||
|
* 1.12× / 1.28× / 1.45× (starting points to refine on-device).
|
||||||
|
*/
|
||||||
|
internal fun metricsFor(scale: AgendaScale): AgendaMetrics = when (scale) {
|
||||||
|
AgendaScale.COMPACT -> AgendaMetrics(
|
||||||
|
title = 16.sp,
|
||||||
|
dayHeader = 13.sp,
|
||||||
|
eventTitle = 14.sp,
|
||||||
|
eventTime = 12.sp,
|
||||||
|
placeholder = 14.sp,
|
||||||
|
message = 14.sp,
|
||||||
|
stripeH = 36.dp,
|
||||||
|
iconImage = 22.dp,
|
||||||
|
iconBox = 40.dp,
|
||||||
|
rowVPad = 4.dp,
|
||||||
|
)
|
||||||
|
AgendaScale.REGULAR -> AgendaMetrics(
|
||||||
|
title = 18.sp,
|
||||||
|
dayHeader = 14.sp,
|
||||||
|
eventTitle = 15.sp,
|
||||||
|
eventTime = 13.sp,
|
||||||
|
placeholder = 15.sp,
|
||||||
|
message = 15.sp,
|
||||||
|
stripeH = 40.dp,
|
||||||
|
iconImage = 24.dp,
|
||||||
|
iconBox = 44.dp,
|
||||||
|
rowVPad = 5.dp,
|
||||||
|
)
|
||||||
|
AgendaScale.LARGE -> AgendaMetrics(
|
||||||
|
title = 20.sp,
|
||||||
|
dayHeader = 16.sp,
|
||||||
|
eventTitle = 17.sp,
|
||||||
|
eventTime = 14.sp,
|
||||||
|
placeholder = 16.sp,
|
||||||
|
message = 16.sp,
|
||||||
|
stripeH = 46.dp,
|
||||||
|
iconImage = 26.dp,
|
||||||
|
iconBox = 48.dp,
|
||||||
|
rowVPad = 6.dp,
|
||||||
|
)
|
||||||
|
AgendaScale.XLARGE -> AgendaMetrics(
|
||||||
|
title = 22.sp,
|
||||||
|
dayHeader = 18.sp,
|
||||||
|
eventTitle = 19.sp,
|
||||||
|
eventTime = 15.sp,
|
||||||
|
placeholder = 18.sp,
|
||||||
|
message = 18.sp,
|
||||||
|
stripeH = 52.dp,
|
||||||
|
iconImage = 28.dp,
|
||||||
|
iconBox = 52.dp,
|
||||||
|
rowVPad = 8.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buckets a live widget size into an [AgendaScale] by **width**.
|
||||||
|
*
|
||||||
|
* Width is the right axis: it governs how much of a title fits on a row, so it's
|
||||||
|
* what should drive type size. Height only decides how many rows are visible — a
|
||||||
|
* tall, narrow widget wants *more events*, not bigger text — so it never raises
|
||||||
|
* the tier. It does act as a **cap**, though: a very short widget is stepped back
|
||||||
|
* down so it can't keep oversized type in a sliver of space.
|
||||||
|
*
|
||||||
|
* The thresholds are spread across the width range a phone can actually produce
|
||||||
|
* (~180dp up to roughly the screen width) rather than over a theoretical range, so
|
||||||
|
* the tiers are reachable in practice. Calibrated on-device (Pixel / Nova): a
|
||||||
|
* compact 222dp-wide widget stays COMPACT (the app's baseline, unchanged) and a
|
||||||
|
* large 378dp-wide one reaches LARGE. XLARGE is reserved for genuinely wide
|
||||||
|
* surfaces — tablets, foldables, landscape — where the extra size reads well.
|
||||||
|
*/
|
||||||
|
internal fun scaleFor(size: DpSize): AgendaScale {
|
||||||
|
val byWidth = when {
|
||||||
|
size.width < 260.dp -> AgendaScale.COMPACT
|
||||||
|
size.width < 330.dp -> AgendaScale.REGULAR
|
||||||
|
size.width < 420.dp -> AgendaScale.LARGE
|
||||||
|
else -> AgendaScale.XLARGE
|
||||||
|
}
|
||||||
|
// Height can only ever pull the tier *down*, never push it up: a squashed
|
||||||
|
// widget would otherwise keep the big type its width earned and look absurd
|
||||||
|
// in the little space left. Keeping this a cap (rather than a second scaling
|
||||||
|
// axis) is what preserves "tall and narrow shows more events, not bigger text".
|
||||||
|
val heightCap = when {
|
||||||
|
size.height < 220.dp -> AgendaScale.COMPACT
|
||||||
|
size.height < 320.dp -> AgendaScale.REGULAR
|
||||||
|
size.height < 420.dp -> AgendaScale.LARGE
|
||||||
|
else -> AgendaScale.XLARGE
|
||||||
|
}
|
||||||
|
return minOf(byWidth, heightCap)
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import android.content.res.Configuration
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.glance.ColorFilter
|
import androidx.glance.ColorFilter
|
||||||
@@ -14,9 +13,11 @@ import androidx.glance.GlanceModifier
|
|||||||
import androidx.glance.GlanceTheme
|
import androidx.glance.GlanceTheme
|
||||||
import androidx.glance.Image
|
import androidx.glance.Image
|
||||||
import androidx.glance.ImageProvider
|
import androidx.glance.ImageProvider
|
||||||
|
import androidx.glance.LocalSize
|
||||||
import androidx.glance.action.ActionParameters
|
import androidx.glance.action.ActionParameters
|
||||||
import androidx.glance.action.clickable
|
import androidx.glance.action.clickable
|
||||||
import androidx.glance.appwidget.GlanceAppWidget
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
import androidx.glance.appwidget.SizeMode
|
||||||
import androidx.glance.appwidget.action.ActionCallback
|
import androidx.glance.appwidget.action.ActionCallback
|
||||||
import androidx.glance.appwidget.action.actionRunCallback
|
import androidx.glance.appwidget.action.actionRunCallback
|
||||||
import androidx.glance.appwidget.action.actionStartActivity
|
import androidx.glance.appwidget.action.actionStartActivity
|
||||||
@@ -107,6 +108,13 @@ class AgendaWidget : GlanceAppWidget() {
|
|||||||
|
|
||||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||||
|
|
||||||
|
// Exact (not Responsive) so a single copy of the day/event list is laid out at
|
||||||
|
// the widget's live size — Responsive would replicate the whole LazyColumn per
|
||||||
|
// declared tier. The composition reads LocalSize.current and scales type/rows
|
||||||
|
// from it ([scaleFor]/[metricsFor]); at the default size that resolves to
|
||||||
|
// COMPACT, i.e. the layout is unchanged (#51).
|
||||||
|
override val sizeMode = SizeMode.Exact
|
||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val data = context.loadAgendaWidgetData()
|
val data = context.loadAgendaWidgetData()
|
||||||
val dark = (context.resources.configuration.uiMode and
|
val dark = (context.resources.configuration.uiMode and
|
||||||
@@ -136,16 +144,19 @@ private sealed interface AgendaRow {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
||||||
|
// Type and row metrics scale with the widget's live size (SizeMode.Exact); a
|
||||||
|
// short/compact widget resolves to COMPACT, leaving the layout unchanged (#51).
|
||||||
|
val metrics = metricsFor(scaleFor(LocalSize.current))
|
||||||
Column(
|
Column(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(GlanceTheme.colors.surface)
|
.background(GlanceTheme.colors.surface)
|
||||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
) {
|
) {
|
||||||
AgendaHeader()
|
AgendaHeader(metrics)
|
||||||
Spacer(GlanceModifier.height(4.dp))
|
Spacer(GlanceModifier.height(4.dp))
|
||||||
when (data) {
|
when (data) {
|
||||||
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission)
|
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics)
|
||||||
is AgendaWidgetData.Ready -> {
|
is AgendaWidgetData.Ready -> {
|
||||||
// Range read reactively from per-instance Glance state (falls back
|
// Range read reactively from per-instance Glance state (falls back
|
||||||
// to the saved pref for a freshly placed widget), then the wide
|
// to the saved pref for a freshly placed widget), then the wide
|
||||||
@@ -179,7 +190,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
|||||||
enabled = showToday,
|
enabled = showToday,
|
||||||
)
|
)
|
||||||
if (visibleDays.isEmpty()) {
|
if (visibleDays.isEmpty()) {
|
||||||
WidgetMessage(R.string.agenda_empty_title)
|
WidgetMessage(R.string.agenda_empty_title, metrics)
|
||||||
} else {
|
} else {
|
||||||
val rows = buildList {
|
val rows = buildList {
|
||||||
visibleDays.forEach { day ->
|
visibleDays.forEach { day ->
|
||||||
@@ -194,8 +205,8 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
|||||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||||
items(rows.size) { index ->
|
items(rows.size) { index ->
|
||||||
when (val row = rows[index]) {
|
when (val row = rows[index]) {
|
||||||
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
|
is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics)
|
||||||
is AgendaRow.Placeholder -> PlaceholderRow(row.date)
|
is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics)
|
||||||
is AgendaRow.Event -> EventRow(
|
is AgendaRow.Event -> EventRow(
|
||||||
event = row.event,
|
event = row.event,
|
||||||
day = row.date,
|
day = row.date,
|
||||||
@@ -204,6 +215,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
|||||||
is24Hour = data.is24Hour,
|
is24Hour = data.is24Hour,
|
||||||
dimmed = pastDisplay == PastEventDisplay.DIM &&
|
dimmed = pastDisplay == PastEventDisplay.DIM &&
|
||||||
row.event.hasEnded(data.now),
|
row.event.hasEnded(data.now),
|
||||||
|
metrics = metrics,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,7 +227,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaHeader() {
|
private fun AgendaHeader(metrics: AgendaMetrics) {
|
||||||
val context = androidx.glance.LocalContext.current
|
val context = androidx.glance.LocalContext.current
|
||||||
Row(
|
Row(
|
||||||
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
|
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||||
@@ -227,7 +239,7 @@ private fun AgendaHeader() {
|
|||||||
text = context.getString(R.string.widget_agenda_title),
|
text = context.getString(R.string.widget_agenda_title),
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
color = GlanceTheme.colors.primary,
|
color = GlanceTheme.colors.primary,
|
||||||
fontSize = 16.sp,
|
fontSize = metrics.title,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
),
|
),
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
@@ -240,6 +252,7 @@ private fun AgendaHeader() {
|
|||||||
resId = R.drawable.ic_widget_refresh,
|
resId = R.drawable.ic_widget_refresh,
|
||||||
contentDescription = context.getString(R.string.widget_refresh),
|
contentDescription = context.getString(R.string.widget_refresh),
|
||||||
onClick = GlanceModifier.clickable(actionRunCallback<RefreshAgendaAction>()),
|
onClick = GlanceModifier.clickable(actionRunCallback<RefreshAgendaAction>()),
|
||||||
|
metrics = metrics,
|
||||||
)
|
)
|
||||||
IconButton(
|
IconButton(
|
||||||
resId = R.drawable.ic_widget_add,
|
resId = R.drawable.ic_widget_add,
|
||||||
@@ -249,34 +262,40 @@ private fun AgendaHeader() {
|
|||||||
MainActivity.openCreateIntent(context, today(systemZone())),
|
MainActivity.openCreateIntent(context, today(systemZone())),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
metrics = metrics,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun IconButton(resId: Int, contentDescription: String, onClick: GlanceModifier) {
|
private fun IconButton(
|
||||||
|
resId: Int,
|
||||||
|
contentDescription: String,
|
||||||
|
onClick: GlanceModifier,
|
||||||
|
metrics: AgendaMetrics,
|
||||||
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = GlanceModifier.size(40.dp).then(onClick),
|
modifier = GlanceModifier.size(metrics.iconBox).then(onClick),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Image(
|
Image(
|
||||||
provider = ImageProvider(resId),
|
provider = ImageProvider(resId),
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
|
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
|
||||||
modifier = GlanceModifier.size(22.dp),
|
modifier = GlanceModifier.size(metrics.iconImage),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
|
private fun DayHeaderRow(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) {
|
||||||
val context = androidx.glance.LocalContext.current
|
val context = androidx.glance.LocalContext.current
|
||||||
Text(
|
Text(
|
||||||
text = agendaDayLabel(context, date, today),
|
text = agendaDayLabel(context, date, today),
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
color = if (date == today) GlanceTheme.colors.primary
|
color = if (date == today) GlanceTheme.colors.primary
|
||||||
else GlanceTheme.colors.onSurfaceVariant,
|
else GlanceTheme.colors.onSurfaceVariant,
|
||||||
fontSize = 13.sp,
|
fontSize = metrics.dayHeader,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
),
|
),
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
@@ -296,11 +315,11 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
|
|||||||
* plain too (a stripe + text, not cards), so a card here would look out of place.
|
* plain too (a stripe + text, not cards), so a card here would look out of place.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun PlaceholderRow(date: LocalDate) {
|
private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) {
|
||||||
val context = androidx.glance.LocalContext.current
|
val context = androidx.glance.LocalContext.current
|
||||||
Text(
|
Text(
|
||||||
text = context.getString(R.string.agenda_no_more_today),
|
text = context.getString(R.string.agenda_no_more_today),
|
||||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp),
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder),
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(start = 19.dp, end = 8.dp, top = 2.dp, bottom = 6.dp)
|
.padding(start = 19.dp, end = 8.dp, top = 2.dp, bottom = 6.dp)
|
||||||
@@ -320,6 +339,7 @@ private fun EventRow(
|
|||||||
soften: Boolean,
|
soften: Boolean,
|
||||||
is24Hour: Boolean,
|
is24Hour: Boolean,
|
||||||
dimmed: Boolean,
|
dimmed: Boolean,
|
||||||
|
metrics: AgendaMetrics,
|
||||||
) {
|
) {
|
||||||
val context = androidx.glance.LocalContext.current
|
val context = androidx.glance.LocalContext.current
|
||||||
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
@@ -332,7 +352,7 @@ private fun EventRow(
|
|||||||
Row(
|
Row(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 4.dp, vertical = 4.dp)
|
.padding(horizontal = 4.dp, vertical = metrics.rowVPad)
|
||||||
.clickable(
|
.clickable(
|
||||||
actionStartActivity(
|
actionStartActivity(
|
||||||
MainActivity.openEventIntent(
|
MainActivity.openEventIntent(
|
||||||
@@ -349,7 +369,7 @@ private fun EventRow(
|
|||||||
Box(
|
Box(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.width(5.dp)
|
.width(5.dp)
|
||||||
.height(36.dp)
|
.height(metrics.stripeH)
|
||||||
.cornerRadius(3.dp)
|
.cornerRadius(3.dp)
|
||||||
.background(stripeColor),
|
.background(stripeColor),
|
||||||
) {}
|
) {}
|
||||||
@@ -358,19 +378,19 @@ private fun EventRow(
|
|||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
style = TextStyle(color = titleColor, fontSize = 14.sp),
|
style = TextStyle(color = titleColor, fontSize = metrics.eventTitle),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = eventTimeSummary(context, event, day, is24Hour),
|
text = eventTimeSummary(context, event, day, is24Hour),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.eventTime),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WidgetMessage(resId: Int) {
|
private fun WidgetMessage(resId: Int, metrics: AgendaMetrics) {
|
||||||
val context = androidx.glance.LocalContext.current
|
val context = androidx.glance.LocalContext.current
|
||||||
Box(
|
Box(
|
||||||
modifier = GlanceModifier.fillMaxSize().padding(16.dp),
|
modifier = GlanceModifier.fillMaxSize().padding(16.dp),
|
||||||
@@ -378,7 +398,7 @@ private fun WidgetMessage(resId: Int) {
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = context.getString(resId),
|
text = context.getString(resId),
|
||||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp),
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.message),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.agenda
|
||||||
|
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class AgendaScaleTest {
|
||||||
|
|
||||||
|
// --- scaleFor: bucketing -------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the on-device calibration points map to their tiers`() {
|
||||||
|
// The two sizes measured on-device: the compact widget stays COMPACT (the
|
||||||
|
// baseline, unchanged), the large one steps up to LARGE — not XLARGE,
|
||||||
|
// which read as too big on a phone (#51).
|
||||||
|
assertThat(scaleFor(DpSize(222.dp, 270.dp))).isEqualTo(AgendaScale.COMPACT)
|
||||||
|
assertThat(scaleFor(DpSize(378.dp, 672.dp))).isEqualTo(AgendaScale.LARGE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `width buckets into the four tiers`() {
|
||||||
|
// Tall enough that the height cap never binds, isolating the width rule.
|
||||||
|
val h = 500.dp
|
||||||
|
assertThat(scaleFor(DpSize(180.dp, h))).isEqualTo(AgendaScale.COMPACT)
|
||||||
|
assertThat(scaleFor(DpSize(259.dp, h))).isEqualTo(AgendaScale.COMPACT)
|
||||||
|
assertThat(scaleFor(DpSize(260.dp, h))).isEqualTo(AgendaScale.REGULAR)
|
||||||
|
assertThat(scaleFor(DpSize(329.dp, h))).isEqualTo(AgendaScale.REGULAR)
|
||||||
|
assertThat(scaleFor(DpSize(330.dp, h))).isEqualTo(AgendaScale.LARGE)
|
||||||
|
assertThat(scaleFor(DpSize(419.dp, h))).isEqualTo(AgendaScale.LARGE)
|
||||||
|
assertThat(scaleFor(DpSize(420.dp, h))).isEqualTo(AgendaScale.XLARGE)
|
||||||
|
assertThat(scaleFor(DpSize(900.dp, h))).isEqualTo(AgendaScale.XLARGE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `extra height never raises the tier`() {
|
||||||
|
// Height decides how many rows are visible, not how big they are: a tall,
|
||||||
|
// narrow widget wants more events, not bigger text.
|
||||||
|
val short = scaleFor(DpSize(222.dp, 200.dp))
|
||||||
|
val tall = scaleFor(DpSize(222.dp, 900.dp))
|
||||||
|
assertThat(short).isEqualTo(AgendaScale.COMPACT)
|
||||||
|
assertThat(tall).isEqualTo(AgendaScale.COMPACT)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a squashed widget is stepped back down`() {
|
||||||
|
// Same (wide) width, shrinking height: the tier must walk down rather than
|
||||||
|
// keep big type in a sliver of space.
|
||||||
|
val wide = 378.dp
|
||||||
|
assertThat(scaleFor(DpSize(wide, 672.dp))).isEqualTo(AgendaScale.LARGE)
|
||||||
|
assertThat(scaleFor(DpSize(wide, 400.dp))).isEqualTo(AgendaScale.LARGE)
|
||||||
|
assertThat(scaleFor(DpSize(wide, 300.dp))).isEqualTo(AgendaScale.REGULAR)
|
||||||
|
assertThat(scaleFor(DpSize(wide, 200.dp))).isEqualTo(AgendaScale.COMPACT)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the height cap never exceeds what the width earned`() {
|
||||||
|
// A tall but narrow widget stays at its width's tier — the cap only ever
|
||||||
|
// lowers, so a huge height can't promote a 222dp-wide widget.
|
||||||
|
assertThat(scaleFor(DpSize(222.dp, 900.dp))).isEqualTo(AgendaScale.COMPACT)
|
||||||
|
assertThat(scaleFor(DpSize(300.dp, 900.dp))).isEqualTo(AgendaScale.REGULAR)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- metricsFor: the "default unchanged" regression guard -----------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `COMPACT metrics equal the widget's original constants`() {
|
||||||
|
// If this fails, a default-sized agenda widget no longer looks as it did.
|
||||||
|
val m = metricsFor(AgendaScale.COMPACT)
|
||||||
|
assertThat(m.title).isEqualTo(16.sp)
|
||||||
|
assertThat(m.dayHeader).isEqualTo(13.sp)
|
||||||
|
assertThat(m.eventTitle).isEqualTo(14.sp)
|
||||||
|
assertThat(m.eventTime).isEqualTo(12.sp)
|
||||||
|
assertThat(m.placeholder).isEqualTo(14.sp)
|
||||||
|
assertThat(m.message).isEqualTo(14.sp)
|
||||||
|
assertThat(m.stripeH).isEqualTo(36.dp)
|
||||||
|
assertThat(m.iconImage).isEqualTo(22.dp)
|
||||||
|
assertThat(m.iconBox).isEqualTo(40.dp)
|
||||||
|
assertThat(m.rowVPad).isEqualTo(4.dp)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `type sizes are non-decreasing across the tiers`() {
|
||||||
|
val tiers = listOf(
|
||||||
|
AgendaScale.COMPACT,
|
||||||
|
AgendaScale.REGULAR,
|
||||||
|
AgendaScale.LARGE,
|
||||||
|
AgendaScale.XLARGE,
|
||||||
|
).map(::metricsFor)
|
||||||
|
|
||||||
|
tiers.zipWithNext { small, big ->
|
||||||
|
assertThat(big.title.value).isAtLeast(small.title.value)
|
||||||
|
assertThat(big.dayHeader.value).isAtLeast(small.dayHeader.value)
|
||||||
|
assertThat(big.eventTitle.value).isAtLeast(small.eventTitle.value)
|
||||||
|
assertThat(big.eventTime.value).isAtLeast(small.eventTime.value)
|
||||||
|
assertThat(big.placeholder.value).isAtLeast(small.placeholder.value)
|
||||||
|
assertThat(big.message.value).isAtLeast(small.message.value)
|
||||||
|
assertThat(big.stripeH.value).isAtLeast(small.stripeH.value)
|
||||||
|
assertThat(big.iconImage.value).isAtLeast(small.iconImage.value)
|
||||||
|
assertThat(big.iconBox.value).isAtLeast(small.iconBox.value)
|
||||||
|
assertThat(big.rowVPad.value).isAtLeast(small.rowVPad.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user