revert(widget): leave the month grid responsive, as it always was (#103)
The month widget's columns were never the problem. MonthWidget.kt has divided the width it is given by seven since v2.5.0 and 2.16.0 did not touch that line — only the title formatter. What 2.16.0 did change was AgendaWidget, which gained SizeMode.Exact and became structurally identical to MonthWidget, so R8 merged the two classes and collapsed Glance's provider map. That is #89, and it is what handed the month grid the wrong widget's measurements. The keep rule from #89 is the fix. Reverting the fixed-size grid, its metrics table and the raised minResizeWidth. The size setting stays, agenda-only, which is all #51 ever asked for.
This commit is contained in:
29
CHANGELOG.md
29
CHANGELOG.md
@@ -28,11 +28,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- The calendar picker in the event form and in the .ics import screen now ends
|
||||
with a **"Missing a calendar?"** row that opens Settings → Calendars, where
|
||||
those marks then explain why a calendar isn't offered ([#76]).
|
||||
- Both home-screen widgets have a size you set yourself. **Settings → Widgets →
|
||||
Widget size** offers Small, Medium, Large and Extra large: it sets how big the
|
||||
agenda widget's text is, and how big the month widget's day columns and text
|
||||
are. Small is what the widgets look like today, so nothing changes until you
|
||||
turn it up ([#51], [#103]).
|
||||
- The agenda widget's text size is yours to set. **Settings → Agenda → Agenda
|
||||
widget size** offers Small, Medium, Large and Extra large, replacing the guess
|
||||
the widget used to make from its own measurements. Small is what it looks like
|
||||
today, so nothing changes until you turn it up ([#51]).
|
||||
|
||||
### Changed
|
||||
- Calendula's source code now lives on **Codeberg**, where its issues already
|
||||
@@ -42,19 +41,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
unaffected.
|
||||
|
||||
### Fixed
|
||||
- The month widget shows all seven days again. Unless it happened to be resized
|
||||
to just the right shape, it drew only about four columns and cut the last one
|
||||
off part-way through — the rest of the week was simply missing. The widget was
|
||||
sizing its columns from the width the launcher reported, which is not the width
|
||||
it is actually drawn into, so the columns came out too wide to fit. Nothing in
|
||||
either widget is measured any more: they draw at the size you pick under
|
||||
Settings → Widgets. The month widget can no longer be resized narrower than its
|
||||
grid needs, either ([#103], [#51]).
|
||||
- A month-grid widget stays a month grid. Since 2.16.0 a placed month widget
|
||||
could redraw itself as the agenda widget a little after any change to your
|
||||
events, because the release build merged the two widgets into a single class
|
||||
and Android could no longer tell which of them a widget on your home screen
|
||||
was ([#89]).
|
||||
- A month-grid widget stays a month grid, and draws all seven days again. Since
|
||||
2.16.0 a placed month widget could redraw itself as the agenda widget a little
|
||||
after any change to your events, and could draw only about four day columns
|
||||
with the last one cut off part-way through. Both came from the release build
|
||||
merging the two widgets into a single class, so Android could no longer tell
|
||||
which of them a widget on your home screen was — and the month grid was handed
|
||||
the wrong widget's measurements to lay its columns out against ([#89], [#103]).
|
||||
- The back gesture on **Settings → Views** returns to Settings instead of
|
||||
leaving Settings altogether and dropping you on the calendar. Special dates
|
||||
did the same ([#81]).
|
||||
|
||||
@@ -308,13 +308,13 @@ class SettingsPrefs @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* The size step both home-screen widgets draw themselves at (#103, #51).
|
||||
* Defaults to [WidgetSize.SMALL], which reproduces their original metrics, so
|
||||
* an existing widget is unchanged until its owner turns the size up.
|
||||
* The size step the agenda widget draws its text at (#51). Defaults to
|
||||
* [WidgetSize.SMALL], which reproduces its original metrics, so an existing
|
||||
* widget is unchanged until its owner turns the size up.
|
||||
*
|
||||
* This replaced deriving a size tier from the widget's measured size: the
|
||||
* width a launcher reports is not the width the widget is drawn into, and the
|
||||
* month grid sized its seven columns from it and lost the last three.
|
||||
* This replaced deriving a size tier from the widget's measured size, which
|
||||
* the launcher does not report reliably. The month widget takes no size
|
||||
* setting — it divides the width it is given by seven (#103).
|
||||
*/
|
||||
val widgetSize: Flow<WidgetSize> = store.data.map { prefs ->
|
||||
prefs[WIDGET_SIZE_KEY].toEnum(WidgetSize.SMALL)
|
||||
|
||||
@@ -687,6 +687,12 @@ private fun AppearanceScreen(
|
||||
},
|
||||
onClick = { viewModel.setAgendaShowToday(!state.agendaShowToday) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_widget_size),
|
||||
summary = widgetSizeLabel(state.widgetSize),
|
||||
position = Position.Middle,
|
||||
onClick = { showWidgetSize = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_agenda_range_bar),
|
||||
summary = stringResource(R.string.settings_agenda_range_bar_hint),
|
||||
@@ -702,18 +708,6 @@ private fun AppearanceScreen(
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Widgets — the size step applies to both home-screen widgets, so it sits
|
||||
// in its own group rather than under Agenda.
|
||||
SectionHeader(stringResource(R.string.settings_widgets_header))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_widget_size),
|
||||
summary = widgetSizeLabel(state.widgetSize),
|
||||
position = Position.Alone,
|
||||
onClick = { showWidgetSize = true },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// App name — chooses the launcher label between "Calendula" and "Calendar"
|
||||
// (issue #44). Own group: it's a launcher/system concern, not calendar
|
||||
// formatting. A sub-page chooser (not a switch), matching the app's other
|
||||
|
||||
@@ -49,7 +49,7 @@ data class SettingsUiState(
|
||||
val agendaShowToday: Boolean = true,
|
||||
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */
|
||||
val agendaShowRangeBar: Boolean = true,
|
||||
/** The size step both home-screen widgets draw themselves at (#103, #51). */
|
||||
/** The size step the agenda widget draws its text at (#51). */
|
||||
val widgetSize: WidgetSize = WidgetSize.SMALL,
|
||||
/** The calendar view the app opens on, and the home of the view back stack (M1). */
|
||||
val defaultView: CalendarView = CalendarView.Week,
|
||||
|
||||
@@ -45,7 +45,6 @@ import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SHOW_TODAY_STATE_KEY
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SIZE_KEY
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
||||
import de.jeanlucmakiola.calendula.widget.month.MONTH_SIZE_KEY
|
||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -485,10 +484,13 @@ class SettingsViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the size step both widgets draw at (#103, #51). Pushed into every
|
||||
* Set the size the agenda widget draws its text at (#51). Pushed into every
|
||||
* instance's Glance state and recomposed — the same reliable-update path as
|
||||
* [setAgendaWidgetRange], since `updateAll` alone won't re-run the data
|
||||
* preamble a live session already ran.
|
||||
*
|
||||
* Agenda-only on purpose: the month widget sizes itself from the space it is
|
||||
* given and always has, so it takes no size setting (#103).
|
||||
*/
|
||||
fun setWidgetSize(size: WidgetSize) {
|
||||
viewModelScope.launch {
|
||||
@@ -498,11 +500,7 @@ class SettingsViewModel @Inject constructor(
|
||||
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
||||
updateAppWidgetState(appContext, id) { it[AGENDA_SIZE_KEY] = size.name }
|
||||
}
|
||||
manager.getGlanceIds(MonthWidget::class.java).forEach { id ->
|
||||
updateAppWidgetState(appContext, id) { it[MONTH_SIZE_KEY] = size.name }
|
||||
}
|
||||
AgendaWidget().updateAll(appContext)
|
||||
MonthWidget().updateAll(appContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
/**
|
||||
* The size step a widget draws itself at — a **user setting**, not something
|
||||
* derived from the widget's measured size (#103, #51).
|
||||
* The size step the **agenda** widget draws its text and rows at — a user
|
||||
* setting, not something derived from the widget's measured size (#51).
|
||||
*
|
||||
* Both Glance widgets used to bucket their live size (`SizeMode.Exact` +
|
||||
* The agenda widget used to bucket its live size (`SizeMode.Exact` +
|
||||
* `LocalSize.current`) into a tier and scale from that. The size a launcher
|
||||
* reports is not the width the widget is actually drawn into, which broke the
|
||||
* month grid outright: its seven columns were sized from that number, came out
|
||||
* too wide, and only the first four fitted. Nothing here reads a measured size
|
||||
* any more — the user picks a step, every metric follows from it, and both
|
||||
* widgets declare `SizeMode.Single`.
|
||||
* reports is not the size the widget is drawn into, so the tier it picked could
|
||||
* disagree with what was on screen; a size the user sets is predictable and is
|
||||
* what the #51 thread actually asked for.
|
||||
*
|
||||
* [SMALL] is the default and reproduces the widgets' original constants, so an
|
||||
* The month widget deliberately has no size setting: its grid divides whatever
|
||||
* width it is given by seven and always has, which is the behaviour to keep
|
||||
* (#103).
|
||||
*
|
||||
* [SMALL] is the default and reproduces the widget's original constants, so an
|
||||
* existing widget looks as it did until its owner turns the size up.
|
||||
*/
|
||||
enum class WidgetSize { SMALL, MEDIUM, LARGE, EXTRA_LARGE }
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.widget.month
|
||||
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
|
||||
/** Event rows (lanes) shown per week before the rest collapse into "+N". */
|
||||
internal const val MAX_LANES = 3
|
||||
|
||||
/**
|
||||
* Every size the month widget draws from, resolved from the user's chosen
|
||||
* [WidgetSize].
|
||||
*
|
||||
* [columnWidth] is the load-bearing one. The grid lays its seven day columns out
|
||||
* at this fixed width and centres the result in whatever space the host gives it,
|
||||
* rather than dividing a launcher-reported width by seven (#103) — a reported
|
||||
* width that overshoots the real one used to push the last columns off the edge.
|
||||
* A fixed column also keeps a multi-day event a single connected bar
|
||||
* `columnWidth * n` wide, which is why the columns can't simply be weighted.
|
||||
*/
|
||||
internal data class MonthMetrics(
|
||||
val columnWidth: Dp, // one day column
|
||||
val gridPadding: Dp, // horizontal padding either side of the grid
|
||||
val laneHeight: Dp, // one event-bar row
|
||||
val dayNumberHeight: Dp, // day-number row, and the today circle's diameter
|
||||
val headerTitle: TextUnit,
|
||||
val weekday: TextUnit, // narrow weekday initials
|
||||
val dayNumber: TextUnit,
|
||||
val eventTitle: TextUnit, // title inside an event bar
|
||||
val overflow: TextUnit, // the "+N" line
|
||||
val iconImage: Dp, // header arrow/today glyph
|
||||
val iconBox: Dp, // header action touch target
|
||||
) {
|
||||
/** Width the seven columns plus their padding occupy — what a placement must fit. */
|
||||
val gridWidth: Dp get() = columnWidth * 7 + gridPadding * 2
|
||||
}
|
||||
|
||||
/*
|
||||
* SMALL reproduces the widget's original constants so an existing widget is
|
||||
* unchanged until its owner turns the size up — except [columnWidth], which had
|
||||
* no fixed value before (it was derived from the reported width, at roughly 33dp
|
||||
* on a default 4-cell placement). 30dp sits just under that so the smallest step
|
||||
* still fits the narrowest placement the provider allows; see
|
||||
* `appwidget_info_month.xml`'s minResizeWidth, which is pinned against
|
||||
* SMALL.gridWidth by a test.
|
||||
*/
|
||||
|
||||
private val SMALL_METRICS = MonthMetrics(
|
||||
columnWidth = 30.dp,
|
||||
gridPadding = 4.dp,
|
||||
laneHeight = 14.dp,
|
||||
dayNumberHeight = 18.dp,
|
||||
headerTitle = 15.sp,
|
||||
weekday = 11.sp,
|
||||
dayNumber = 11.sp,
|
||||
eventTitle = 9.sp,
|
||||
overflow = 9.sp,
|
||||
iconImage = 20.dp,
|
||||
iconBox = 40.dp,
|
||||
)
|
||||
|
||||
private val MEDIUM_METRICS = MonthMetrics(
|
||||
columnWidth = 38.dp,
|
||||
gridPadding = 6.dp,
|
||||
laneHeight = 16.dp,
|
||||
dayNumberHeight = 22.dp,
|
||||
headerTitle = 17.sp,
|
||||
weekday = 12.sp,
|
||||
dayNumber = 13.sp,
|
||||
eventTitle = 10.sp,
|
||||
overflow = 10.sp,
|
||||
iconImage = 22.dp,
|
||||
iconBox = 44.dp,
|
||||
)
|
||||
|
||||
private val LARGE_METRICS = MonthMetrics(
|
||||
columnWidth = 46.dp,
|
||||
gridPadding = 8.dp,
|
||||
laneHeight = 19.dp,
|
||||
dayNumberHeight = 26.dp,
|
||||
headerTitle = 19.sp,
|
||||
weekday = 13.sp,
|
||||
dayNumber = 15.sp,
|
||||
eventTitle = 11.sp,
|
||||
overflow = 11.sp,
|
||||
iconImage = 24.dp,
|
||||
iconBox = 48.dp,
|
||||
)
|
||||
|
||||
private val EXTRA_LARGE_METRICS = MonthMetrics(
|
||||
columnWidth = 54.dp,
|
||||
gridPadding = 8.dp,
|
||||
laneHeight = 22.dp,
|
||||
dayNumberHeight = 30.dp,
|
||||
headerTitle = 22.sp,
|
||||
weekday = 14.sp,
|
||||
dayNumber = 17.sp,
|
||||
eventTitle = 12.sp,
|
||||
overflow = 12.sp,
|
||||
iconImage = 26.dp,
|
||||
iconBox = 52.dp,
|
||||
)
|
||||
|
||||
/** Indexed by [WidgetSize.ordinal] so lookup allocates nothing per recomposition. */
|
||||
private val MONTH_METRICS = listOf(
|
||||
SMALL_METRICS,
|
||||
MEDIUM_METRICS,
|
||||
LARGE_METRICS,
|
||||
EXTRA_LARGE_METRICS,
|
||||
)
|
||||
|
||||
internal fun monthMetricsFor(size: WidgetSize): MonthMetrics = MONTH_METRICS[size.ordinal]
|
||||
@@ -6,7 +6,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.glance.ColorFilter
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
@@ -14,6 +13,7 @@ import androidx.glance.GlanceTheme
|
||||
import androidx.glance.Image
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.LocalSize
|
||||
import androidx.glance.action.ActionParameters
|
||||
import androidx.glance.action.actionParametersOf
|
||||
import androidx.glance.action.clickable
|
||||
@@ -56,7 +56,6 @@ import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource
|
||||
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||
import de.jeanlucmakiola.calendula.widget.today
|
||||
@@ -74,13 +73,11 @@ import java.util.Locale
|
||||
/** Per-widget state: the displayed month as `year * 12 + monthOrdinal`. */
|
||||
private val MONTH_INDEX_KEY = intPreferencesKey("month_index")
|
||||
|
||||
/**
|
||||
* Per-instance Glance state key holding the chosen [WidgetSize]. Read reactively
|
||||
* in the composition so changing the setting reflects on a live widget by plain
|
||||
* recomposition — `updateAll` does not reliably re-run the `provideGlance`
|
||||
* preamble for a live session (same reason as [MONTH_INDEX_KEY]).
|
||||
*/
|
||||
internal val MONTH_SIZE_KEY = stringPreferencesKey("widget_size")
|
||||
/** Event rows (lanes) shown per week before the rest collapse into "+N". */
|
||||
private const val MAX_LANES = 3
|
||||
private val LANE_HEIGHT = 14.dp
|
||||
private val DAY_NUMBER_HEIGHT = 18.dp
|
||||
private val GRID_HPADDING = 8.dp
|
||||
|
||||
private fun currentMonthIndex(zone: TimeZone): Int {
|
||||
val t = today(zone)
|
||||
@@ -95,45 +92,27 @@ private fun yearMonthOf(index: Int): YearMonth =
|
||||
* event bars and titled single-day pills (the in-app lane layout via
|
||||
* [layoutMonthWeeks]), and prev/next/today navigation.
|
||||
*
|
||||
* Columns are a fixed [MonthMetrics.columnWidth] wide — the user's chosen
|
||||
* [WidgetSize], never a measured size — and the grid is centred in whatever space
|
||||
* the host gives it. Sizing the columns off the launcher-reported width is what
|
||||
* broke the grid (#103): the reported width overshot the width actually drawn
|
||||
* into, so `width / 7` came out too wide and only the first four columns fitted.
|
||||
* A fixed column also keeps a multi-day event one connected Box spanning its
|
||||
* columns — no inter-cell seam, rounded end caps — which weighted columns, the
|
||||
* other way to be measurement-free, could not express.
|
||||
*
|
||||
* The displayed month lives in Glance state and is read reactively in the
|
||||
* composition ([currentState]) so the arrows move it via plain recomposition, not
|
||||
* a (here-unreliable) widget session reload.
|
||||
* Columns are sized explicitly from [LocalSize] (hence [SizeMode.Exact]) so a
|
||||
* multi-day span renders as a single Box spanning its columns — connected, no
|
||||
* inter-cell seam, with rounded end caps. The displayed month lives in Glance
|
||||
* state and is read reactively in the composition ([currentState]) so the arrows
|
||||
* move it via plain recomposition, not a (here-unreliable) widget session reload.
|
||||
*/
|
||||
class MonthWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
|
||||
// Single, not Exact: nothing in the layout depends on the widget's measured
|
||||
// size any more (#103), so there is no reason to pay for one RemoteViews per
|
||||
// host size bucket.
|
||||
override val sizeMode = SizeMode.Single
|
||||
override val sizeMode = SizeMode.Exact
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val source = context.loadMonthWidgetSource()
|
||||
val dark = (context.resources.configuration.uiMode and
|
||||
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||
// Read fresh (not through the cached source) so toggling the softener or
|
||||
// the size redraws with the new choice; two cheap DataStore reads.
|
||||
val prefs = context.widgetEntryPoint().settingsPrefs()
|
||||
val soften = prefs.softenCalendarColors.first()
|
||||
val savedSize = prefs.widgetSize.first()
|
||||
// Read fresh (not through the cached source) so toggling the softener
|
||||
// redraws with the new choice; it's one cheap DataStore read.
|
||||
val soften = context.widgetEntryPoint().settingsPrefs().softenCalendarColors.first()
|
||||
provideContent {
|
||||
CalendulaGlanceTheme {
|
||||
MonthWidgetBody(
|
||||
source = source,
|
||||
dark = dark,
|
||||
soften = soften,
|
||||
savedSize = savedSize,
|
||||
)
|
||||
MonthWidgetBody(source = source, dark = dark, soften = soften)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,35 +143,29 @@ class ResetMonthAction : ActionCallback {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonthWidgetBody(
|
||||
source: MonthWidgetSource,
|
||||
dark: Boolean,
|
||||
soften: Boolean,
|
||||
savedSize: WidgetSize,
|
||||
) {
|
||||
// Size read reactively from per-instance Glance state (falling back to the
|
||||
// saved pref for a freshly placed widget), never from the measured size.
|
||||
val metrics = monthMetricsFor(currentState(MONTH_SIZE_KEY).toWidgetSize(savedSize))
|
||||
private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Boolean) {
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.background(GlanceTheme.colors.surface)
|
||||
.padding(horizontal = metrics.gridPadding, vertical = 6.dp),
|
||||
.padding(horizontal = GRID_HPADDING, vertical = 6.dp),
|
||||
) {
|
||||
when (source) {
|
||||
MonthWidgetSource.NeedsPermission -> {
|
||||
MonthHeader(label = "Calendula", metrics = metrics)
|
||||
MonthHeader(label = "Calendula")
|
||||
PermissionMessage()
|
||||
}
|
||||
is MonthWidgetSource.Ready -> {
|
||||
val zone = systemZone()
|
||||
val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone)
|
||||
val ym = yearMonthOf(index)
|
||||
// Column width from the live widget size, minus our H padding.
|
||||
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
|
||||
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
|
||||
|
||||
MonthHeader(label = monthLabel(ym, source.today.year), metrics = metrics)
|
||||
MonthHeader(label = monthLabel(ym, source.today.year))
|
||||
Spacer(GlanceModifier.height(2.dp))
|
||||
WeekdayHeader(weekStart = source.weekStart, metrics = metrics)
|
||||
WeekdayHeader(weekStart = source.weekStart, colW = colW)
|
||||
weeks.forEach { week ->
|
||||
WeekRow(
|
||||
week = week,
|
||||
@@ -200,7 +173,7 @@ private fun MonthWidgetBody(
|
||||
today = source.today,
|
||||
dark = dark,
|
||||
soften = soften,
|
||||
metrics = metrics,
|
||||
colW = colW,
|
||||
modifier = GlanceModifier.defaultWeight(),
|
||||
)
|
||||
}
|
||||
@@ -209,26 +182,8 @@ private fun MonthWidgetBody(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A grid row: the seven fixed-width columns, centred in the space the host gave
|
||||
* us. Every row of the grid goes through this so they can never drift out of
|
||||
* alignment, and centring means leftover width shows as an even margin either
|
||||
* side rather than a ragged edge (turn the size up to fill it).
|
||||
*/
|
||||
@Composable
|
||||
private fun GridRow(content: @Composable androidx.glance.layout.RowScope.() -> Unit) {
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
private fun String?.toWidgetSize(default: WidgetSize): WidgetSize =
|
||||
this?.let { stored -> WidgetSize.entries.firstOrNull { it.name == stored } } ?: default
|
||||
|
||||
@Composable
|
||||
private fun MonthHeader(label: String, metrics: MonthMetrics) {
|
||||
private fun MonthHeader(label: String) {
|
||||
val context = LocalContext.current
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth(),
|
||||
@@ -237,7 +192,6 @@ private fun MonthHeader(label: String, metrics: MonthMetrics) {
|
||||
HeaderIcon(
|
||||
resId = R.drawable.ic_widget_chevron_left,
|
||||
contentDescription = context.getString(R.string.widget_prev_month),
|
||||
metrics = metrics,
|
||||
onClick = GlanceModifier.clickable(
|
||||
actionRunCallback<ShiftMonthAction>(
|
||||
actionParametersOf(ShiftMonthAction.deltaKey to -1),
|
||||
@@ -250,7 +204,7 @@ private fun MonthHeader(label: String, metrics: MonthMetrics) {
|
||||
text = label,
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.primary,
|
||||
fontSize = metrics.headerTitle,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
@@ -266,13 +220,11 @@ private fun MonthHeader(label: String, metrics: MonthMetrics) {
|
||||
HeaderIcon(
|
||||
resId = R.drawable.ic_widget_today,
|
||||
contentDescription = context.getString(R.string.widget_today),
|
||||
metrics = metrics,
|
||||
onClick = GlanceModifier.clickable(actionRunCallback<ResetMonthAction>()),
|
||||
)
|
||||
HeaderIcon(
|
||||
resId = R.drawable.ic_widget_chevron_right,
|
||||
contentDescription = context.getString(R.string.widget_next_month),
|
||||
metrics = metrics,
|
||||
onClick = GlanceModifier.clickable(
|
||||
actionRunCallback<ShiftMonthAction>(
|
||||
actionParametersOf(ShiftMonthAction.deltaKey to 1),
|
||||
@@ -283,37 +235,32 @@ private fun MonthHeader(label: String, metrics: MonthMetrics) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeaderIcon(
|
||||
resId: Int,
|
||||
contentDescription: String,
|
||||
metrics: MonthMetrics,
|
||||
onClick: GlanceModifier,
|
||||
) {
|
||||
private fun HeaderIcon(resId: Int, contentDescription: String, onClick: GlanceModifier) {
|
||||
Box(
|
||||
modifier = GlanceModifier.size(metrics.iconBox).then(onClick),
|
||||
modifier = GlanceModifier.size(40.dp).then(onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
provider = ImageProvider(resId),
|
||||
contentDescription = contentDescription,
|
||||
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
|
||||
modifier = GlanceModifier.size(metrics.iconImage),
|
||||
modifier = GlanceModifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekdayHeader(weekStart: DayOfWeek, metrics: MonthMetrics) {
|
||||
GridRow {
|
||||
private fun WeekdayHeader(weekStart: DayOfWeek, colW: Dp) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
weekdayNarrowNames(weekStart).forEach { name ->
|
||||
Text(
|
||||
text = name,
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = metrics.weekday,
|
||||
fontSize = 11.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
modifier = GlanceModifier.width(metrics.columnWidth),
|
||||
modifier = GlanceModifier.width(colW),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -336,30 +283,29 @@ private fun WeekRow(
|
||||
today: LocalDate,
|
||||
dark: Boolean,
|
||||
soften: Boolean,
|
||||
metrics: MonthMetrics,
|
||||
colW: Dp,
|
||||
modifier: GlanceModifier,
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
// Day numbers.
|
||||
GridRow {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
week.days.forEach { date ->
|
||||
DayNumber(
|
||||
date = date,
|
||||
isToday = date == today,
|
||||
inMonth = date.month == currentMonth,
|
||||
metrics = metrics,
|
||||
colW = colW,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(GlanceModifier.height(2.dp))
|
||||
// One lane row per event row. A multi-day span is a single Box spanning
|
||||
// its columns (columnWidth * n) so it's connected with no seam and
|
||||
// rounded ends.
|
||||
// its columns (colW * n) so it's connected with no seam and rounded ends.
|
||||
repeat(MAX_LANES) { lane ->
|
||||
LaneRow(week = week, lane = lane, dark = dark, soften = soften, metrics = metrics)
|
||||
LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW)
|
||||
Spacer(GlanceModifier.height(1.dp))
|
||||
}
|
||||
OverflowRow(week = week, metrics = metrics)
|
||||
OverflowRow(week = week, colW = colW)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,20 +319,19 @@ private fun openDayAction(context: Context, date: LocalDate) =
|
||||
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month))
|
||||
|
||||
@Composable
|
||||
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, metrics: MonthMetrics) {
|
||||
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
||||
val context = LocalContext.current
|
||||
val diameter = metrics.dayNumberHeight
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(metrics.columnWidth)
|
||||
.height(diameter)
|
||||
.width(colW)
|
||||
.height(DAY_NUMBER_HEIGHT)
|
||||
.clickable(openDayAction(context, date)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.size(diameter)
|
||||
.then(if (isToday) GlanceModifier.cornerRadius(diameter / 2).background(GlanceTheme.colors.primary) else GlanceModifier),
|
||||
.size(DAY_NUMBER_HEIGHT)
|
||||
.then(if (isToday) GlanceModifier.cornerRadius(DAY_NUMBER_HEIGHT / 2).background(GlanceTheme.colors.primary) else GlanceModifier),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
@@ -397,7 +342,7 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, metri
|
||||
inMonth -> GlanceTheme.colors.onSurface
|
||||
else -> GlanceTheme.colors.onSurfaceVariant
|
||||
},
|
||||
fontSize = metrics.dayNumber,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
|
||||
),
|
||||
)
|
||||
@@ -406,39 +351,27 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, metri
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, metrics: MonthMetrics) {
|
||||
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, colW: Dp) {
|
||||
val context = LocalContext.current
|
||||
GridRow {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
var col = 0
|
||||
while (col < 7) {
|
||||
val span = week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
|
||||
if (span != null) {
|
||||
val cols = span.endCol - col + 1
|
||||
SpanBar(
|
||||
event = span.event,
|
||||
dark = dark,
|
||||
soften = soften,
|
||||
width = metrics.columnWidth * cols,
|
||||
metrics = metrics,
|
||||
)
|
||||
SpanBar(event = span.event, dark = dark, soften = soften, width = colW * cols)
|
||||
col = span.endCol + 1
|
||||
} else {
|
||||
val timed = timedEventAt(week, lane, col, week.days[col])
|
||||
if (timed != null) {
|
||||
SpanBar(
|
||||
event = timed,
|
||||
dark = dark,
|
||||
soften = soften,
|
||||
width = metrics.columnWidth,
|
||||
metrics = metrics,
|
||||
)
|
||||
SpanBar(event = timed, dark = dark, soften = soften, width = colW)
|
||||
} else {
|
||||
// Empty lane cell: a tap opens that day, so blank space in a
|
||||
// day column is a day-open target just like the number is.
|
||||
Box(
|
||||
GlanceModifier
|
||||
.width(metrics.columnWidth)
|
||||
.height(metrics.laneHeight)
|
||||
.width(colW)
|
||||
.height(LANE_HEIGHT)
|
||||
.clickable(openDayAction(context, week.days[col])),
|
||||
) {}
|
||||
}
|
||||
@@ -450,19 +383,13 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean,
|
||||
|
||||
/** A single connected, rounded event bar [width] wide with its clipped title. */
|
||||
@Composable
|
||||
private fun SpanBar(
|
||||
event: EventInstance,
|
||||
dark: Boolean,
|
||||
soften: Boolean,
|
||||
width: Dp,
|
||||
metrics: MonthMetrics,
|
||||
) {
|
||||
private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width: Dp) {
|
||||
val context = LocalContext.current
|
||||
val fill = eventFill(event.color, dark, soften)
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(width)
|
||||
.height(metrics.laneHeight)
|
||||
.height(LANE_HEIGHT)
|
||||
.padding(horizontal = 1.dp)
|
||||
// Tap an event bar to open its detail, rooted in the month view.
|
||||
.clickable(
|
||||
@@ -487,7 +414,7 @@ private fun SpanBar(
|
||||
Text(
|
||||
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = ColorProvider(eventInk(fill)), fontSize = metrics.eventTitle),
|
||||
style = TextStyle(color = ColorProvider(eventInk(fill)), fontSize = 9.sp),
|
||||
modifier = GlanceModifier.padding(horizontal = 3.dp),
|
||||
)
|
||||
}
|
||||
@@ -495,9 +422,9 @@ private fun SpanBar(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OverflowRow(week: MonthWeek, metrics: MonthMetrics) {
|
||||
private fun OverflowRow(week: MonthWeek, colW: Dp) {
|
||||
val context = LocalContext.current
|
||||
GridRow {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
week.days.forEachIndexed { col, date ->
|
||||
val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
||||
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
|
||||
@@ -507,8 +434,8 @@ private fun OverflowRow(week: MonthWeek, metrics: MonthMetrics) {
|
||||
// it shows "+N" or is blank) opens that day, same as the app.
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(metrics.columnWidth)
|
||||
.height(metrics.laneHeight)
|
||||
.width(colW)
|
||||
.height(LANE_HEIGHT)
|
||||
.clickable(openDayAction(context, date)),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
@@ -516,7 +443,7 @@ private fun OverflowRow(week: MonthWeek, metrics: MonthMetrics) {
|
||||
Text(
|
||||
text = "+$hidden",
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.overflow),
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
|
||||
modifier = GlanceModifier.padding(start = 3.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -355,9 +355,8 @@
|
||||
<string name="settings_agenda_widget_range">Agenda widget range</string>
|
||||
<string name="settings_agenda_widget_range_hint">How far ahead the agenda home-screen widget lists events.</string>
|
||||
|
||||
<string name="settings_widgets_header">Widgets</string>
|
||||
<string name="settings_widget_size">Widget size</string>
|
||||
<string name="settings_widget_size_hint">How large both home-screen widgets draw their text and, for the month widget, its day columns. Pick a bigger size to fill a bigger widget.</string>
|
||||
<string name="settings_widget_size">Agenda widget size</string>
|
||||
<string name="settings_widget_size_hint">How large the agenda home-screen widget draws its text. The month widget has no setting — its grid always fits itself to the space you give it.</string>
|
||||
<string name="settings_widget_size_small">Small</string>
|
||||
<string name="settings_widget_size_medium">Medium</string>
|
||||
<string name="settings_widget_size_large">Large</string>
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- minResizeWidth is the floor the smallest widget size step still fits its
|
||||
seven day columns in (#103); MonthScaleTest pins it against SMALL.gridWidth. -->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="180dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="4"
|
||||
android:minResizeWidth="250dp"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="150dp"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:widgetCategory="home_screen"
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.widget.month
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class MonthScaleTest {
|
||||
|
||||
/**
|
||||
* `appwidget_info_month.xml`'s `minResizeWidth` — the narrowest the host will
|
||||
* let a month widget be resized to. Mirrored here because the smallest size
|
||||
* step has to fit inside it; the two move together or the grid clips again.
|
||||
*/
|
||||
private val MIN_RESIZE_WIDTH = 250.dp
|
||||
|
||||
// --- the #103 guarantee: seven columns always fit -------------------------
|
||||
|
||||
@Test
|
||||
fun `the smallest step fits the narrowest placement the provider allows`() {
|
||||
// This is the whole fix. The grid is laid out at a fixed width, so the
|
||||
// default step must fit the narrowest widget a user can drag it down to —
|
||||
// otherwise columns fall off the edge exactly as they did when the width
|
||||
// came from the launcher (#103). The slack covers host chrome, which is
|
||||
// not part of the declared size.
|
||||
val small = monthMetricsFor(WidgetSize.SMALL).gridWidth
|
||||
assertThat(small.value).isLessThan(MIN_RESIZE_WIDTH.value)
|
||||
assertThat((MIN_RESIZE_WIDTH - small).value).isAtLeast(24f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every step lays out exactly seven columns plus its padding`() {
|
||||
WidgetSize.entries.map(::monthMetricsFor).forEach { m ->
|
||||
assertThat(m.gridWidth).isEqualTo(m.columnWidth * 7 + m.gridPadding * 2)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the largest step still fits a full-width phone widget`() {
|
||||
// EXTRA_LARGE is opt-in, but it should be reachable on a normal handset
|
||||
// rather than being a tablet-only trap.
|
||||
assertThat(monthMetricsFor(WidgetSize.EXTRA_LARGE).gridWidth.value).isAtMost(400f)
|
||||
}
|
||||
|
||||
// --- the ramp ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `sizes are non-decreasing across the steps`() {
|
||||
monthMetricsFor(WidgetSize.SMALL)
|
||||
WidgetSize.entries.map(::monthMetricsFor).zipWithNext { small, big ->
|
||||
assertThat(big.columnWidth.value).isAtLeast(small.columnWidth.value)
|
||||
assertThat(big.laneHeight.value).isAtLeast(small.laneHeight.value)
|
||||
assertThat(big.dayNumberHeight.value).isAtLeast(small.dayNumberHeight.value)
|
||||
assertThat(big.headerTitle.value).isAtLeast(small.headerTitle.value)
|
||||
assertThat(big.weekday.value).isAtLeast(small.weekday.value)
|
||||
assertThat(big.dayNumber.value).isAtLeast(small.dayNumber.value)
|
||||
assertThat(big.eventTitle.value).isAtLeast(small.eventTitle.value)
|
||||
assertThat(big.overflow.value).isAtLeast(small.overflow.value)
|
||||
assertThat(big.iconImage.value).isAtLeast(small.iconImage.value)
|
||||
assertThat(big.iconBox.value).isAtLeast(small.iconBox.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the today circle always fits inside its column`() {
|
||||
// The day number sits in a circle dayNumberHeight across, centred in a
|
||||
// column. If it ever outgrew the column it would collide with its
|
||||
// neighbours.
|
||||
WidgetSize.entries.map(::monthMetricsFor).forEach { m ->
|
||||
assertThat(m.dayNumberHeight.value).isLessThan(m.columnWidth.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SMALL keeps the widget's original row metrics`() {
|
||||
// SMALL is the default, so a month widget whose owner never touched the
|
||||
// setting must keep the metrics it shipped with.
|
||||
val m = monthMetricsFor(WidgetSize.SMALL)
|
||||
assertThat(m.laneHeight).isEqualTo(14.dp)
|
||||
assertThat(m.dayNumberHeight).isEqualTo(18.dp)
|
||||
assertThat(m.headerTitle.value).isEqualTo(15f)
|
||||
assertThat(m.weekday.value).isEqualTo(11f)
|
||||
assertThat(m.dayNumber.value).isEqualTo(11f)
|
||||
assertThat(m.eventTitle.value).isEqualTo(9f)
|
||||
assertThat(m.overflow.value).isEqualTo(9f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `three lanes plus the day number stay inside a compact week row`() {
|
||||
// A week row gets roughly a sixth of the grid's height. If the metrics
|
||||
// outgrew that, lanes would be clipped rather than merely tight.
|
||||
val m = monthMetricsFor(WidgetSize.SMALL)
|
||||
val weekRow = m.dayNumberHeight + 2.dp + (m.laneHeight + 1.dp) * MAX_LANES + m.laneHeight
|
||||
assertThat(weekRow.value).isLessThan(90f)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user