diff --git a/app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetViewBudgetTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetPayloadTest.kt similarity index 82% rename from app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetViewBudgetTest.kt rename to app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetPayloadTest.kt index f57f1d2..301155a 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetViewBudgetTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetPayloadTest.kt @@ -4,6 +4,7 @@ import android.Manifest import android.content.ContentUris import android.content.ContentValues import android.net.Uri +import android.os.Parcel import android.provider.CalendarContract import android.util.Log import android.view.View @@ -29,12 +30,12 @@ import java.time.ZoneId /** * Renders the month widget for a packed month through Glance's own translator - * and counts what it inflates (#372): the view-id model in [WeekLayout] has to - * match what Glance really builds, or a busy month fails with "too many views". + * (#372) and checks what one size costs on the wire: the host's binder buffer is + * 1 MB for every widget update at once, and Exact sends one copy per size (#214). */ @OptIn(ExperimentalGlanceApi::class) @RunWith(AndroidJUnit4::class) -class MonthWidgetViewBudgetTest { +class MonthWidgetPayloadTest { @get:Rule val permissionRule: GrantPermissionRule = @@ -95,31 +96,39 @@ class MonthWidgetViewBudgetTest { } @Test - fun aPackedMonthRendersWithinGlancesViewIds() = runBlocking { + fun aPackedMonthRendersWithinItsPayload() = runBlocking { for (size in listOf(DpSize(250.dp, 250.dp), DpSize(320.dp, 360.dp), DpSize(420.dp, 520.dp))) { val views = MonthWidget().compose(context, size = size) - var total = 0 + val bytes = Parcel.obtain().let { parcel -> + try { + views.writeToParcel(parcel, 0) + parcel.dataSize() + } finally { + parcel.recycle() + } + } var texts = 0 instrumentation.runOnMainSync { val root = views.apply(context, FrameLayout(context)) fun walk(view: View) { - total += 1 if (view is TextView) texts += 1 if (view is ViewGroup) for (i in 0 until view.childCount) walk(view.getChildAt(i)) } walk(root) } - Log.i(TAG, "size=$size views=$total texts=$texts budget=$VIEW_BUDGET") + Log.i(TAG, "size=$size bytes=$bytes texts=$texts") // The grid itself rendered, not Glance's error layout: every day number is there. - assertThat(texts).isAtLeast(35) - assertThat(total).isAtMost(GLANCE_VIEW_IDS) + assertThat(texts).isAtLeast(42) + assertThat(bytes).isAtMost(MAX_BYTES_PER_SIZE) } } private companion object { - const val TAG = "MonthWidgetBudget" - const val ACCOUNT = "calendula-widget-budget-test" + const val TAG = "MonthWidgetPayload" + const val ACCOUNT = "calendula-widget-payload-test" const val EVENTS_PER_DAY = 6 - const val GLANCE_VIEW_IDS = 499 + + /** A quarter of the host's buffer, leaving room for a second size and every other widget. */ + const val MAX_BYTES_PER_SIZE = 256 * 1024 } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt index c4d7828..d567e4f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt @@ -1,46 +1,11 @@ package de.jeanlucmakiola.calendula.widget.month -import android.os.Build +import androidx.compose.ui.unit.Dp import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.ui.month.DaySeating import de.jeanlucmakiola.calendula.ui.month.MonthWeek import de.jeanlucmakiola.calendula.ui.month.seating import de.jeanlucmakiola.calendula.ui.month.spanAt -import kotlinx.datetime.LocalDate - -/** Event rows (lanes) a week draws at most before the rest collapse into "+N". */ -internal const val MAX_LANES = 3 - -/** - * View ids one rendering may spend. Glance has 499 and fails the whole widget - * past them (#372); staying well under also keeps a busy month's payload, sent - * once per host size, clear of the binder limit #214 ran into. - */ -internal const val VIEW_BUDGET = 400 - -/** - * What views cost in Glance 1.2's view ids. A clickable view that isn't a - * Button is wrapped in a Box sized like it, with a ripple Image and the view - * itself filling it. Below Android 12 a view with a fixed width or height is - * inflated into a sizing frame first, which takes a second id. - */ -internal class ViewIds(sdkInt: Int = Build.VERSION.SDK_INT) { - val plain = 1 - val sized = if (sdkInt < Build.VERSION_CODES.S) 2 else 1 - val clickablePlain = plain + 2 - val clickableSized = sized + 2 - - /** - * `MonthWidgetBody`'s views outside the week rows: the surface Box, its - * Column, the header row with its clickable title Box, title Text and (on a - * transparent widget) the spacer after the title pill, two - * sized clickable icon pills with their sized icons, the sized clickable - * today pill with its Text and the two sized action gaps, the spacer, the - * grid's Column and the weekday row of sized labels. - */ - val fixed = plain + plain + plain + clickablePlain + plain + plain + 2 * (clickableSized + sized) + - (clickableSized + plain) + 2 * sized + sized + plain + plain + 7 * sized -} /** A cell [cols] columns wide holding [item], or a merged gap where [item] is null. */ internal data class GridCell(val item: T?, val cols: Int) @@ -71,7 +36,7 @@ internal fun mergeGaps(columns: Int, cellAt: (Int) -> GridCell?): L return cells } -/** One week of the grid seated with [laneCap] lanes, computed once for both drawing and counting. */ +/** One week of the grid seated with [laneCap] lanes. */ internal class WeekLayout(val week: MonthWeek, val laneCap: Int) { private val seating: List = week.days.indices.map { week.seating(it, laneCap) } @@ -92,57 +57,22 @@ internal class WeekLayout(val week: MonthWeek, val laneCap: Int) { if (hidden.none { it > 0 }) emptyList() else mergeGaps(hidden.size) { col -> hidden[col].takeIf { it > 0 }?.let { GridCell(it, 1) } } } - - /** - * The view ids `WeekRow` spends on this week, counted the way it draws it: - * the week Box, the strip Row and its seven sized clickable strips, the - * content Column, the sized day-number row and its sized numbers (today's - * pill adds a sized Box around a plain Text), the sized spacer, each lane's - * Row and sized spacer with sized gaps and bars (a sized Box around a - * filling Box and Text), and the "+N" Row of sized cells. - */ - fun viewIds(today: LocalDate, ids: ViewIds): Int { - val days = week.days.size - val strips = ids.plain + days * ids.clickableSized - val numbers = ids.sized + days * ids.sized + if (today in week.days) ids.sized + ids.plain else 0 - val lanes = lanes.sumOf { cells -> - ids.plain + ids.sized + cells.sumOf { if (it.item == null) ids.sized else ids.sized + 2 * ids.plain } - } - val overflow = if (overflow.isEmpty()) 0 else ids.plain + overflow.size * ids.sized - return ids.plain + strips + ids.plain + numbers + ids.sized + lanes + overflow - } } +/** Event lanes a week row [weekHeight] tall holds under its day number. */ +internal fun lanesFitting(weekHeight: Dp): Int = + ((weekHeight.value - DAY_ROW_DP - LANES_TOP_DP) / LANE_ROW_DP).toInt().coerceAtLeast(0) + /** - * Seats each week so the whole grid fits [budget] view ids (#372). Each round - * folds the lowest lanes of whichever week saves the most ids per lane given - * up, their events joining "+N", until the month fits. + * Seats each week in [lanes] lanes. A week whose events need more gives its + * last lane to the "+N" row instead. */ -internal fun layoutWeeks( - weeks: List, - today: LocalDate, - ids: ViewIds = ViewIds(), - budget: Int = VIEW_BUDGET - ids.fixed, -): List { - val layouts = weeks.map { week -> WeekLayout(week, WeekLayout(week, MAX_LANES).usedLanes) }.toMutableList() - val costs = layouts.map { it.viewIds(today, ids) }.toMutableList() - while (costs.sum() > budget) { - var best: Triple? = null - var bestRate = 0.0 - layouts.forEachIndexed { i, layout -> - for (cap in layout.laneCap - 1 downTo 0) { - val folded = WeekLayout(layout.week, cap) - val cost = folded.viewIds(today, ids) - val rate = (costs[i] - cost).toDouble() / (layout.laneCap - cap) - if (rate > bestRate) { - bestRate = rate - best = Triple(i, folded, cost) - } - } - } - val (i, folded, cost) = best ?: break - layouts[i] = folded - costs[i] = cost - } - return layouts +internal fun fitWeeks(weeks: List, lanes: Int): List = weeks.map { week -> + val needed = WeekLayout(week, UNCAPPED_LANES).usedLanes + WeekLayout(week, if (needed <= lanes) lanes else (lanes - 1).coerceAtLeast(0)) } + +private const val DAY_ROW_DP = 18f +private const val LANES_TOP_DP = 2f +private const val LANE_ROW_DP = 15f +private const val UNCAPPED_LANES = 64 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt new file mode 100644 index 0000000..88db8c4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt @@ -0,0 +1,177 @@ +package de.jeanlucmakiola.calendula.widget.month + +import android.app.PendingIntent +import android.content.Context +import android.content.res.Configuration +import android.graphics.Paint +import android.os.Build +import android.view.View +import android.widget.RemoteViews +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.glance.unit.ColorProvider +import de.jeanlucmakiola.calendula.MainActivity +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.isDeclined +import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.eventFill +import de.jeanlucmakiola.calendula.ui.common.eventInk +import de.jeanlucmakiola.floret.glance.WidgetThemeMode +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month + +/** A colour for the light and the dark widget theme; equal when the widget pins one. */ +internal data class DayNight(val day: Int, val night: Int) { + fun current(context: Context): Int = + if (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES) night else day +} + +/** The theme colours the plain-RemoteViews grid draws with, resolved out of Glance's providers. */ +internal data class GridColors( + val onSurface: DayNight, + val onSurfaceVariant: DayNight, + val primary: DayNight, + val onPrimary: DayNight, +) + +internal fun ColorProvider.dayNight(context: Context, mode: WidgetThemeMode): DayNight { + fun at(night: Boolean): Int { + val config = Configuration(context.resources.configuration).apply { + uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or + if (night) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO + } + return getColor(context.createConfigurationContext(config)).toArgb() + } + return when (mode) { + WidgetThemeMode.SYSTEM -> DayNight(day = at(false), night = at(true)) + WidgetThemeMode.LIGHT -> at(false).let { DayNight(it, it) } + WidgetThemeMode.DARK -> at(true).let { DayNight(it, it) } + } +} + +private fun pinned(day: Color, night: Color, mode: WidgetThemeMode): DayNight = when (mode) { + WidgetThemeMode.SYSTEM -> DayNight(day.toArgb(), night.toArgb()) + WidgetThemeMode.LIGHT -> DayNight(day.toArgb(), day.toArgb()) + WidgetThemeMode.DARK -> DayNight(night.toArgb(), night.toArgb()) +} + +/** + * The month's week rows as one plain RemoteViews tree, embedded in the Glance + * widget so its views don't draw on Glance's view-id pool (#372): every event + * gets its bar, and only what can't fit the widget's height folds into "+N". + * + * A host may reapply a new rendering onto the views of the last one rather than + * inflate it afresh, so every container is cleared before it's filled and every + * view state is set both ways, never just when it differs from the layout. + */ +internal fun monthGridViews( + context: Context, + layouts: List, + currentMonth: Month, + today: LocalDate, + soften: Boolean, + colors: GridColors, + mode: WidgetThemeMode, +): RemoteViews { + val grid = RemoteViews(context.packageName, R.layout.widget_month_grid) + grid.removeAllViews(R.id.month_grid) + layouts.forEach { grid.addView(R.id.month_grid, weekViews(context, it, currentMonth, today, soften, colors, mode)) } + return grid +} + +private fun weekViews( + context: Context, + layout: WeekLayout, + currentMonth: Month, + today: LocalDate, + soften: Boolean, + colors: GridColors, + mode: WidgetThemeMode, +): RemoteViews { + val week = RemoteViews(context.packageName, R.layout.widget_month_week) + week.removeAllViews(R.id.week_lanes) + layout.week.days.forEachIndexed { col, date -> + week.setOnClickPendingIntent(STRIP_IDS[col], openDayIntent(context, date)) + val dayId = DAY_IDS[col] + week.setTextViewText(dayId, date.day.toString()) + val isToday = date == today + week.setColor(context, dayId, "setTextColor", when { + isToday -> colors.onPrimary + date.month == currentMonth -> colors.onSurface + else -> colors.onSurfaceVariant + }) + week.setViewVisibility(TODAY_IDS[col], if (isToday) View.VISIBLE else View.GONE) + if (isToday) week.setColor(context, TODAY_IDS[col], "setColorFilter", colors.primary) + } + layout.lanes.forEach { cells -> + val lane = laneViews(context) + cells.forEach { cell -> + val event = cell.item + lane.addView(R.id.lane, if (event == null) gap(context, cell.cols) else bar(context, event, cell.cols, soften, mode)) + } + week.addView(R.id.week_lanes, lane) + } + if (layout.overflow.isNotEmpty()) { + val lane = laneViews(context) + layout.overflow.forEach { cell -> + val count = cell.item + if (count == null) { + lane.addView(R.id.lane, gap(context, cell.cols)) + } else { + val more = RemoteViews(context.packageName, R.layout.widget_month_more) + more.setTextViewText(R.id.more, "+$count") + more.setColor(context, R.id.more, "setTextColor", colors.onSurfaceVariant) + lane.addView(R.id.lane, more) + } + } + week.addView(R.id.week_lanes, lane) + } + return week +} + +private fun laneViews(context: Context) = + RemoteViews(context.packageName, R.layout.widget_month_lane).apply { removeAllViews(R.id.lane) } + +private fun gap(context: Context, cols: Int) = RemoteViews(context.packageName, GAP_LAYOUTS[cols - 1]) + +private fun bar(context: Context, event: EventInstance, cols: Int, soften: Boolean, mode: WidgetThemeMode): RemoteViews { + val views = RemoteViews(context.packageName, BAR_LAYOUTS[cols - 1]) + val dayFill = eventFill(event.color, dark = false, soften) + val nightFill = eventFill(event.color, dark = true, soften) + views.setColor(context, R.id.bar_bg, "setColorFilter", pinned(dayFill, nightFill, mode)) + views.setTextViewText(R.id.bar_title, event.title.ifBlank { context.getString(R.string.event_untitled) }) + views.setColor(context, R.id.bar_title, "setTextColor", pinned(eventInk(dayFill), eventInk(nightFill), mode)) + val strike = if (event.isDeclined) Paint.STRIKE_THRU_TEXT_FLAG else 0 + views.setInt(R.id.bar_title, "setPaintFlags", strike or Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG) + return views +} + +private fun RemoteViews.setColor(context: Context, id: Int, method: String, color: DayNight) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && color.day != color.night) { + setColorInt(id, method, color.day, color.night) + } else { + setInt(id, method, color.current(context)) + } +} + +private fun openDayIntent(context: Context, date: LocalDate): PendingIntent = PendingIntent.getActivity( + context, + date.toEpochDays().toInt(), + MainActivity.openDateIntent(context, date, CalendarView.Month), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, +) + +private val STRIP_IDS = intArrayOf(R.id.strip_0, R.id.strip_1, R.id.strip_2, R.id.strip_3, R.id.strip_4, R.id.strip_5, R.id.strip_6) +private val DAY_IDS = intArrayOf(R.id.day_0, R.id.day_1, R.id.day_2, R.id.day_3, R.id.day_4, R.id.day_5, R.id.day_6) +private val TODAY_IDS = intArrayOf( + R.id.today_bg_0, R.id.today_bg_1, R.id.today_bg_2, R.id.today_bg_3, R.id.today_bg_4, R.id.today_bg_5, R.id.today_bg_6, +) +private val BAR_LAYOUTS = intArrayOf( + R.layout.widget_month_bar_1, R.layout.widget_month_bar_2, R.layout.widget_month_bar_3, R.layout.widget_month_bar_4, + R.layout.widget_month_bar_5, R.layout.widget_month_bar_6, R.layout.widget_month_bar_7, +) +private val GAP_LAYOUTS = intArrayOf( + R.layout.widget_month_gap_1, R.layout.widget_month_gap_2, R.layout.widget_month_gap_3, R.layout.widget_month_gap_4, + R.layout.widget_month_gap_5, R.layout.widget_month_gap_6, R.layout.widget_month_gap_7, +) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt index b89270e..b756d09 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt @@ -23,6 +23,7 @@ import androidx.glance.action.ActionParameters import androidx.glance.action.actionParametersOf import androidx.glance.action.clickable import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.AndroidRemoteViews import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.action.actionRunCallback @@ -46,6 +47,7 @@ import androidx.glance.layout.padding import androidx.glance.layout.size import androidx.glance.layout.width import androidx.glance.state.PreferencesGlanceStateDefinition +import de.jeanlucmakiola.floret.glance.LocalWidgetThemeMode import de.jeanlucmakiola.floret.glance.WidgetActionStyle import de.jeanlucmakiola.floret.glance.WidgetHeader import de.jeanlucmakiola.floret.glance.WidgetHeaderAction @@ -93,10 +95,11 @@ import java.util.Locale /** Per-widget state: the displayed month as `year * 12 + monthOrdinal`. */ private val MONTH_INDEX_KEY = intPreferencesKey("month_index") -private val LANE_HEIGHT = 14.dp -private val DAY_NUMBER_HEIGHT = 18.dp private val GRID_HPADDING = 8.dp +/** Height around the week rows: body padding, header, its spacer and the weekday row. */ +private val GRID_CHROME_HEIGHT = 62.dp + private fun currentMonthIndex(zone: TimeZone): Int = monthIndexOf(today(zone)) private fun yearMonthOf(index: Int): YearMonth = @@ -107,9 +110,9 @@ 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 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 + * The week rows are one plain RemoteViews tree ([monthGridViews]) embedded in + * the Glance widget, so a busy month isn't held to Glance's view-id pool (#372); + * the lanes each week shows come from the widget's height. 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. * @@ -120,7 +123,7 @@ private fun yearMonthOf(index: Int): YearMonth = * the launcher rebinds. [SizeMode.Exact] serialises the grid once per size the * host reports, so the ceiling arrives at half the view count you would expect: a * grid that emitted a view per cell blew past it after three taps (#214). Every - * row here emits only what it fills. + * row here emits only what it fills, and lanes stop at what the height shows. */ class MonthWidget : GlanceAppWidget() { @@ -130,8 +133,8 @@ class MonthWidget : GlanceAppWidget() { * Exact rather than [SizeMode.Single] or [SizeMode.Responsive], both of which * return early from `GlanceAppWidget.resize` — under those the grid is never * recomposed for a new size and a resized widget clips instead of reflowing. - * Exact costs one serialised copy per host size, which is why a rendering is - * held to [VIEW_BUDGET] view ids, folding lanes into "+N" on a busy month. + * Exact costs one serialised copy per host size, so the grid draws only the + * lanes the size can show, folding the rest into "+N". */ override val sizeMode = SizeMode.Exact @@ -241,22 +244,26 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) { // 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) + val weekHeight = (LocalSize.current.height - GRID_CHROME_HEIGHT) / weeks.size + val layouts = fitWeeks(weeks, lanesFitting(weekHeight)) + val context = LocalContext.current + val mode = LocalWidgetThemeMode.current + val colors = GridColors( + onSurface = GlanceTheme.colors.onSurface.dayNight(context, mode), + onSurfaceVariant = GlanceTheme.colors.onSurfaceVariant.dayNight(context, mode), + primary = GlanceTheme.colors.primary.dayNight(context, mode), + onPrimary = GlanceTheme.colors.onPrimary.dayNight(context, mode), + ) MonthHeader(label = monthLabel(ym, source.today.year), today = source.today) Spacer(GlanceModifier.height(4.dp)) // One backing for the whole grid on a transparent widget (#292). Column(modifier = GlanceModifier.fillMaxWidth().defaultWeight().wallpaperBacking()) { WeekdayHeader(weekStart = source.weekStart, colW = colW) - layoutWeeks(weeks, source.today).forEach { layout -> - WeekRow( - layout = layout, - currentMonth = ym.month, - today = source.today, - soften = source.soften, - colW = colW, - modifier = GlanceModifier.defaultWeight(), - ) - } + AndroidRemoteViews( + remoteViews = monthGridViews(context, layouts, ym.month, source.today, source.soften, colors, mode), + modifier = GlanceModifier.fillMaxWidth().defaultWeight(), + ) } } } @@ -324,176 +331,6 @@ private fun weekdayNarrowNames(weekStart: DayOfWeek): List { } } -@Composable -private fun WeekRow( - layout: WeekLayout, - currentMonth: Month, - today: LocalDate, - soften: Boolean, - colW: Dp, - modifier: GlanceModifier, -) { - val context = LocalContext.current - val week = layout.week - Box(modifier = modifier.fillMaxWidth()) { - // Day-open targets, one full-height strip per column, sitting *under* the - // content: a tap anywhere in a day still opens it, as in the app, without - // every cell in the column carrying a click target of its own. The content - // above is not clickable, event bars included, so touches fall through to - // this layer and a day opens wherever it is tapped (#386). - Row(modifier = GlanceModifier.fillMaxSize()) { - week.days.forEach { date -> - Box( - modifier = GlanceModifier - .width(colW) - .fillMaxHeight() - .clickable(openDayAction(context, date)), - ) {} - } - } - Column(modifier = GlanceModifier.fillMaxWidth()) { - // Day numbers. Fixed height so every week's number row lines up whether - // or not it holds today's larger pill. - Row( - modifier = GlanceModifier.fillMaxWidth().height(DAY_NUMBER_HEIGHT), - verticalAlignment = Alignment.CenterVertically, - ) { - week.days.forEach { date -> - DayNumber( - date = date, - isToday = date == today, - inMonth = date.month == currentMonth, - colW = colW, - ) - } - } - Spacer(GlanceModifier.height(2.dp)) - // One lane row per event row. A multi-day span is a single Box spanning - // its columns (colW * n) so it's connected with no seam and rounded ends. - // Lanes past the last one this week fills emit nothing at all. - // The grid's view budget caps the lanes per week (#372); what doesn't - // fit folds into the "+N" row. Counted the same way by WeekLayout.viewIds. - layout.lanes.forEach { cells -> - LaneRow(cells = cells, soften = soften, colW = colW) - Spacer(GlanceModifier.height(1.dp)) - } - if (layout.overflow.isNotEmpty()) OverflowRow(cells = layout.overflow, colW = colW) - } - } -} - -/** - * Open [date]'s day view rooted in the month view (so back returns to the month - * grid) — the same target the in-app month grid uses when a day cell is tapped. - * The only tap target in a day column, so a tap anywhere on a day opens it. - */ -private fun openDayAction(context: Context, date: LocalDate) = - actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month)) - -/** - * A day's number. Every day but today is a bare centred [Text] carrying the column - * width itself — a wrapping Box costs three views, and there are 42 of these. - * Today keeps its filled circle, which does need the Box. - */ -@Composable -private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) { - val style = TextStyle( - color = when { - isToday -> GlanceTheme.colors.onPrimary - inMonth -> GlanceTheme.colors.onSurface - else -> GlanceTheme.colors.onSurfaceVariant - }, - fontSize = 11.sp, - fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, - textAlign = TextAlign.Center, - ) - if (!isToday) { - Text(text = date.day.toString(), style = style, modifier = GlanceModifier.width(colW)) - return - } - Box(modifier = GlanceModifier.width(colW), contentAlignment = Alignment.Center) { - Box( - modifier = GlanceModifier - .size(DAY_NUMBER_HEIGHT) - .cornerRadius(DAY_NUMBER_HEIGHT / 2) - .background(GlanceTheme.colors.primary), - contentAlignment = Alignment.Center, - ) { - Text(text = date.day.toString(), style = style) - } - } -} - -@Composable -private fun LaneRow(cells: List>, soften: Boolean, colW: Dp) { - Row(modifier = GlanceModifier.fillMaxWidth()) { - cells.forEach { cell -> - val event = cell.item - if (event == null) { - Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT)) - } else { - SpanBar( - event = event, - soften = soften, - width = colW * cell.cols, - ) - } - } - } -} - -/** A single connected, rounded event bar [width] wide with its clipped title. */ -@Composable -private fun SpanBar(event: EventInstance, soften: Boolean, width: Dp) { - val context = LocalContext.current - val dayFill = eventFill(event.color, dark = false, soften) - val nightFill = eventFill(event.color, dark = true, soften) - Box( - modifier = GlanceModifier - .width(width) - .height(LANE_HEIGHT) - .padding(horizontal = 1.dp), - ) { - Box( - modifier = GlanceModifier - .fillMaxSize() - .cornerRadius(4.dp) - .background(themedColor(day = dayFill, night = nightFill)), - contentAlignment = Alignment.CenterStart, - ) { - Text( - text = event.title.ifBlank { context.getString(R.string.event_untitled) }, - maxLines = 1, - style = TextStyle( - color = themedColor(day = eventInk(dayFill), night = eventInk(nightFill)), - fontSize = 9.sp, - textDecoration = glanceDeclinedDecoration(event.isDeclined), - ), - modifier = GlanceModifier.padding(horizontal = 3.dp), - ) - } - } -} - -/** The "+N" row, drawn only for weeks that hide something, gaps merged as in a lane. */ -@Composable -private fun OverflowRow(cells: List>, colW: Dp) { - Row(modifier = GlanceModifier.fillMaxWidth()) { - cells.forEach { cell -> - if (cell.item == null) { - Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT)) - } else { - Text( - text = "+${cell.item}", - maxLines = 1, - style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp), - modifier = GlanceModifier.width(colW).padding(start = 3.dp), - ) - } - } - } -} - @Composable private fun PermissionMessage() { val context = LocalContext.current diff --git a/app/src/main/res/drawable/widget_month_bar_bg.xml b/app/src/main/res/drawable/widget_month_bar_bg.xml new file mode 100644 index 0000000..eb18a82 --- /dev/null +++ b/app/src/main/res/drawable/widget_month_bar_bg.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_month_today_bg.xml b/app/src/main/res/drawable/widget_month_today_bg.xml new file mode 100644 index 0000000..3626003 --- /dev/null +++ b/app/src/main/res/drawable/widget_month_today_bg.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/layout/widget_month_bar_1.xml b/app/src/main/res/layout/widget_month_bar_1.xml new file mode 100644 index 0000000..38b7c5a --- /dev/null +++ b/app/src/main/res/layout/widget_month_bar_1.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/layout/widget_month_bar_2.xml b/app/src/main/res/layout/widget_month_bar_2.xml new file mode 100644 index 0000000..5310c85 --- /dev/null +++ b/app/src/main/res/layout/widget_month_bar_2.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/layout/widget_month_bar_3.xml b/app/src/main/res/layout/widget_month_bar_3.xml new file mode 100644 index 0000000..75eb3c3 --- /dev/null +++ b/app/src/main/res/layout/widget_month_bar_3.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/layout/widget_month_bar_4.xml b/app/src/main/res/layout/widget_month_bar_4.xml new file mode 100644 index 0000000..bb6557e --- /dev/null +++ b/app/src/main/res/layout/widget_month_bar_4.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/layout/widget_month_bar_5.xml b/app/src/main/res/layout/widget_month_bar_5.xml new file mode 100644 index 0000000..6cd75a8 --- /dev/null +++ b/app/src/main/res/layout/widget_month_bar_5.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/layout/widget_month_bar_6.xml b/app/src/main/res/layout/widget_month_bar_6.xml new file mode 100644 index 0000000..e0cf5f2 --- /dev/null +++ b/app/src/main/res/layout/widget_month_bar_6.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/layout/widget_month_bar_7.xml b/app/src/main/res/layout/widget_month_bar_7.xml new file mode 100644 index 0000000..17d7b32 --- /dev/null +++ b/app/src/main/res/layout/widget_month_bar_7.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/layout/widget_month_gap_1.xml b/app/src/main/res/layout/widget_month_gap_1.xml new file mode 100644 index 0000000..6d18a12 --- /dev/null +++ b/app/src/main/res/layout/widget_month_gap_1.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/layout/widget_month_gap_2.xml b/app/src/main/res/layout/widget_month_gap_2.xml new file mode 100644 index 0000000..5d6f690 --- /dev/null +++ b/app/src/main/res/layout/widget_month_gap_2.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/layout/widget_month_gap_3.xml b/app/src/main/res/layout/widget_month_gap_3.xml new file mode 100644 index 0000000..d73cef0 --- /dev/null +++ b/app/src/main/res/layout/widget_month_gap_3.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/layout/widget_month_gap_4.xml b/app/src/main/res/layout/widget_month_gap_4.xml new file mode 100644 index 0000000..b0ddf81 --- /dev/null +++ b/app/src/main/res/layout/widget_month_gap_4.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/layout/widget_month_gap_5.xml b/app/src/main/res/layout/widget_month_gap_5.xml new file mode 100644 index 0000000..d36a2aa --- /dev/null +++ b/app/src/main/res/layout/widget_month_gap_5.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/layout/widget_month_gap_6.xml b/app/src/main/res/layout/widget_month_gap_6.xml new file mode 100644 index 0000000..6da1869 --- /dev/null +++ b/app/src/main/res/layout/widget_month_gap_6.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/layout/widget_month_gap_7.xml b/app/src/main/res/layout/widget_month_gap_7.xml new file mode 100644 index 0000000..6ba0099 --- /dev/null +++ b/app/src/main/res/layout/widget_month_gap_7.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/layout/widget_month_grid.xml b/app/src/main/res/layout/widget_month_grid.xml new file mode 100644 index 0000000..bdf0ebf --- /dev/null +++ b/app/src/main/res/layout/widget_month_grid.xml @@ -0,0 +1,6 @@ + + diff --git a/app/src/main/res/layout/widget_month_lane.xml b/app/src/main/res/layout/widget_month_lane.xml new file mode 100644 index 0000000..57a8df1 --- /dev/null +++ b/app/src/main/res/layout/widget_month_lane.xml @@ -0,0 +1,8 @@ + + diff --git a/app/src/main/res/layout/widget_month_more.xml b/app/src/main/res/layout/widget_month_more.xml new file mode 100644 index 0000000..63f9b17 --- /dev/null +++ b/app/src/main/res/layout/widget_month_more.xml @@ -0,0 +1,11 @@ + + diff --git a/app/src/main/res/layout/widget_month_week.xml b/app/src/main/res/layout/widget_month_week.xml new file mode 100644 index 0000000..e44c7d9 --- /dev/null +++ b/app/src/main/res/layout/widget_month_week.xml @@ -0,0 +1,220 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt index 4af9e7a..e16697a 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt @@ -13,21 +13,18 @@ import kotlinx.datetime.Month import kotlinx.datetime.TimeZone import kotlinx.datetime.YearMonth import kotlinx.datetime.plus +import androidx.compose.ui.unit.dp import org.junit.jupiter.api.Test -/** The month widget's view budget (#372). */ +/** How the month widget seats a week's events into lanes (#372). */ class MonthGridTest { private val zone = TimeZone.UTC private val jul26 = YearMonth(2026, Month.JULY) - private val today = LocalDate(2026, 7, 15) /** Monday Jul 6 to Sunday Jul 12. */ private val monday = LocalDate(2026, 7, 6) - private val android16 = ViewIds(sdkInt = 36) - private val android10 = ViewIds(sdkInt = 29) - private fun weeks(events: List): List = layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone) @@ -43,33 +40,25 @@ class MonthGridTest { ) } - private fun List.cost(ids: ViewIds) = sumOf { it.viewIds(today, ids) } - @Test - fun `a quiet month keeps every lane it uses`() { - val layouts = layoutWeeks(busyWeeks(perDay = 1), today, android16) - assertThat(layouts.map { it.laneCap }.distinct()).containsExactly(1) + fun `a week that fits keeps every lane`() { + val layouts = fitWeeks(busyWeeks(perDay = 2), lanes = 3) + assertThat(layouts.map { it.laneCap }.distinct()).containsExactly(3) assertThat(layouts.all { it.overflow.isEmpty() }).isTrue() } @Test - fun `a full month folds lanes until it fits, on every Android version`() { - for (ids in listOf(android16, android10)) { - val weeks = busyWeeks(perDay = 5) - assertThat(weeks.map { WeekLayout(it, MAX_LANES) }.cost(ids)).isGreaterThan(VIEW_BUDGET - ids.fixed) - - val layouts = layoutWeeks(weeks, today, ids) - assertThat(layouts.cost(ids)).isAtMost(VIEW_BUDGET - ids.fixed) - assertThat(layouts.any { it.laneCap < MAX_LANES }).isTrue() - } + fun `a week that doesn't fit gives its last lane to the overflow`() { + val layouts = fitWeeks(busyWeeks(perDay = 5), lanes = 3) + assertThat(layouts.map { it.laneCap }.distinct()).containsExactly(2) + assertThat(layouts.all { it.overflow.isNotEmpty() }).isTrue() } @Test - fun `older Android folds more, since a sized view costs it two ids`() { - val weeks = busyWeeks(perDay = 3) - val newer = layoutWeeks(weeks, today, android16).sumOf { it.laneCap } - val older = layoutWeeks(weeks, today, android10).sumOf { it.laneCap } - assertThat(older).isLessThan(newer) + fun `a taller week fits more lanes`() { + assertThat(lanesFitting(19.dp)).isEqualTo(0) + assertThat(lanesFitting(50.dp)).isEqualTo(2) + assertThat(lanesFitting(100.dp)).isEqualTo(5) } @Test @@ -83,7 +72,7 @@ class MonthGridTest { @Test fun `a multi-day bar is one cell as wide as its days`() { val bar = allDayEvent(monday.plus(1, DateTimeUnit.DAY), monday.plus(3, DateTimeUnit.DAY), id = 1) - val layout = WeekLayout(weeks(listOf(bar))[1], MAX_LANES) + val layout = WeekLayout(weeks(listOf(bar))[1], laneCap = 3) assertThat(layout.lanes).hasSize(1) assertThat(layout.lanes[0]).containsExactly(GridCell(null, 1), GridCell(bar, 3), GridCell(null, 3)).inOrder() }