Month widget: draw the weeks as plain RemoteViews, lanes from the widget's height (#372)
Glance's 499 view ids forced busy months down to one or two lanes. The week rows are now one embedded RemoteViews tree, so every event that fits the height gets its bar and only the rest folds into +N. The on-device test now checks the payload instead of the view count.
This commit is contained in:
+21
-12
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<T : Any>(val item: T?, val cols: Int)
|
||||
@@ -71,7 +36,7 @@ internal fun <T : Any> mergeGaps(columns: Int, cellAt: (Int) -> GridCell<T>?): 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<DaySeating> = 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<MonthWeek>,
|
||||
today: LocalDate,
|
||||
ids: ViewIds = ViewIds(),
|
||||
budget: Int = VIEW_BUDGET - ids.fixed,
|
||||
): List<WeekLayout> {
|
||||
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<Int, WeekLayout, Int>? = 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<MonthWeek>, lanes: Int): List<WeekLayout> = 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
|
||||
|
||||
@@ -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<WeekLayout>,
|
||||
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,
|
||||
)
|
||||
@@ -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<String> {
|
||||
}
|
||||
}
|
||||
|
||||
@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<GridCell<EventInstance>>, 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<GridCell<Int>>, 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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<corners android:radius="4dp" />
|
||||
<solid android:color="@android:color/white" />
|
||||
</shape>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
|
||||
<solid android:color="@android:color/white" />
|
||||
</shape>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:paddingHorizontal="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/bar_bg"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_bar_bg" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bar_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_vertical"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:paddingHorizontal="3dp"
|
||||
android:textSize="9sp" />
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="2"
|
||||
android:paddingHorizontal="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/bar_bg"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_bar_bg" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bar_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_vertical"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:paddingHorizontal="3dp"
|
||||
android:textSize="9sp" />
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="3"
|
||||
android:paddingHorizontal="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/bar_bg"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_bar_bg" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bar_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_vertical"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:paddingHorizontal="3dp"
|
||||
android:textSize="9sp" />
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="4"
|
||||
android:paddingHorizontal="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/bar_bg"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_bar_bg" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bar_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_vertical"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:paddingHorizontal="3dp"
|
||||
android:textSize="9sp" />
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="5"
|
||||
android:paddingHorizontal="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/bar_bg"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_bar_bg" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bar_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_vertical"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:paddingHorizontal="3dp"
|
||||
android:textSize="9sp" />
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="6"
|
||||
android:paddingHorizontal="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/bar_bg"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_bar_bg" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bar_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_vertical"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:paddingHorizontal="3dp"
|
||||
android:textSize="9sp" />
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="7"
|
||||
android:paddingHorizontal="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/bar_bg"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_bar_bg" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bar_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_vertical"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:paddingHorizontal="3dp"
|
||||
android:textSize="9sp" />
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1" />
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="2" />
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="3" />
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="4" />
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="5" />
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="6" />
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="7" />
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/month_grid"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical" />
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/lane"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="15dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingBottom="1dp"
|
||||
android:weightSum="7" />
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/more"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center_vertical"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:paddingStart="3dp"
|
||||
android:textSize="9sp" />
|
||||
@@ -0,0 +1,220 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="horizontal">
|
||||
<FrameLayout
|
||||
android:id="@+id/strip_0"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1" />
|
||||
<FrameLayout
|
||||
android:id="@+id/strip_1"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1" />
|
||||
<FrameLayout
|
||||
android:id="@+id/strip_2"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1" />
|
||||
<FrameLayout
|
||||
android:id="@+id/strip_3"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1" />
|
||||
<FrameLayout
|
||||
android:id="@+id/strip_4"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1" />
|
||||
<FrameLayout
|
||||
android:id="@+id/strip_5"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1" />
|
||||
<FrameLayout
|
||||
android:id="@+id/strip_6"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="18dp"
|
||||
android:orientation="horizontal">
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/today_bg_0"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_gravity="center"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/widget_month_today_bg"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/day_0"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:includeFontPadding="false"
|
||||
android:textSize="11sp" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/today_bg_1"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_gravity="center"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/widget_month_today_bg"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/day_1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:includeFontPadding="false"
|
||||
android:textSize="11sp" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/today_bg_2"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_gravity="center"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/widget_month_today_bg"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/day_2"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:includeFontPadding="false"
|
||||
android:textSize="11sp" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/today_bg_3"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_gravity="center"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/widget_month_today_bg"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/day_3"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:includeFontPadding="false"
|
||||
android:textSize="11sp" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/today_bg_4"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_gravity="center"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/widget_month_today_bg"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/day_4"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:includeFontPadding="false"
|
||||
android:textSize="11sp" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/today_bg_5"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_gravity="center"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/widget_month_today_bg"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/day_5"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:includeFontPadding="false"
|
||||
android:textSize="11sp" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/today_bg_6"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_gravity="center"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/widget_month_today_bg"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/day_6"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:includeFontPadding="false"
|
||||
android:textSize="11sp" />
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/week_lanes"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="2dp" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
@@ -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<EventInstance>): List<MonthWeek> =
|
||||
layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone)
|
||||
|
||||
@@ -43,33 +40,25 @@ class MonthGridTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<WeekLayout>.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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user