+31
-16
@@ -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
|
||||
@@ -24,17 +25,18 @@ import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import java.time.LocalDate
|
||||
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 +97,44 @@ class MonthWidgetViewBudgetTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPackedMonthRendersWithinGlancesViewIds() = runBlocking {
|
||||
for (size in listOf(DpSize(250.dp, 250.dp), DpSize(320.dp, 360.dp), DpSize(420.dp, 520.dp))) {
|
||||
fun aPackedMonthRendersWithinItsPayload() = runBlocking {
|
||||
for (size in listOf(DpSize(250.dp, 250.dp), DpSize(320.dp, 360.dp), DpSize(420.dp, 520.dp), DpSize(600.dp, 1000.dp))) {
|
||||
val views = MonthWidget().compose(context, size = size)
|
||||
var total = 0
|
||||
var texts = 0
|
||||
val bytes = Parcel.obtain().let { parcel ->
|
||||
try {
|
||||
views.writeToParcel(parcel, 0)
|
||||
parcel.dataSize()
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
}
|
||||
var dayNumbers = 0
|
||||
var bars = 0
|
||||
instrumentation.runOnMainSync {
|
||||
val root = views.apply(context, FrameLayout(context))
|
||||
fun walk(view: View) {
|
||||
total += 1
|
||||
if (view is TextView) texts += 1
|
||||
if (view.id in DAY_IDS && view is TextView && view.text.isNotEmpty()) dayNumbers += 1
|
||||
if (view.id == R.id.bar_title) bars += 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")
|
||||
// The grid itself rendered, not Glance's error layout: every day number is there.
|
||||
assertThat(texts).isAtLeast(35)
|
||||
assertThat(total).isAtMost(GLANCE_VIEW_IDS)
|
||||
Log.i(TAG, "size=$size bytes=$bytes days=$dayNumbers bars=$bars")
|
||||
// The grid itself rendered, not Glance's error layout: a number in every day of every week.
|
||||
assertThat(dayNumbers % 7).isEqualTo(0)
|
||||
assertThat(dayNumbers).isAtLeast(28)
|
||||
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
|
||||
|
||||
val DAY_IDS = setOf(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)
|
||||
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
@@ -6,17 +6,11 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
|
||||
/**
|
||||
* Horizontal layout constants for an agenda event row. These don't scale with the
|
||||
* size step, since a wider stripe or gap would eat title width, but [TEXT_INDENT]
|
||||
* is derived from them so non-event text can't drift out of alignment.
|
||||
*/
|
||||
internal val ROW_H_PAD = 4.dp
|
||||
internal val STRIPE_WIDTH = 5.dp
|
||||
internal val STRIPE_GAP = 10.dp
|
||||
/** Horizontal inset of a row's text inside its card; doesn't scale, so it never eats title width. */
|
||||
internal val ROW_H_PAD = 12.dp
|
||||
|
||||
/** Left indent that lines non-event text up with the event title column. */
|
||||
internal val TEXT_INDENT = ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP
|
||||
/** Space between the date-pill gutter and a day's card. */
|
||||
internal val GUTTER_GAP = 6.dp
|
||||
|
||||
/**
|
||||
* Every size the agenda widget varies by [WidgetSize]. Values that genuinely
|
||||
@@ -24,90 +18,91 @@ internal val TEXT_INDENT = ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP
|
||||
* constants rather than routing through here.
|
||||
*/
|
||||
internal data class AgendaMetrics(
|
||||
val title: TextUnit, // header "Upcoming"
|
||||
val dayHeader: TextUnit, // day label ("Today · …")
|
||||
val pillWeekday: TextUnit, // date pill weekday ("Thu")
|
||||
val pillDay: TextUnit, // date pill day number
|
||||
val eventTitle: TextUnit, // event title line
|
||||
val eventTime: TextUnit, // time/location line
|
||||
val placeholder: TextUnit, // "no more events today" (#35)
|
||||
val message: TextUnit, // empty/permission centred message
|
||||
val stripeH: Dp, // coloured event stripe height
|
||||
val iconImage: Dp, // header action icon glyph
|
||||
val iconBox: Dp, // header action touch target
|
||||
val contentH: Dp, // an event row's two text lines
|
||||
val pillWidth: Dp, // date pill / gutter width
|
||||
val rowVPad: Dp, // event row vertical padding
|
||||
val dayHeaderTopPad: Dp, // space above a day header
|
||||
val dayGap: Dp, // space between two days' cards
|
||||
val monthLabel: TextUnit, // month name above a new month's first day
|
||||
) {
|
||||
/** One event row's height, shared by the date pill beside it and the placeholder row. */
|
||||
val rowHeight: Dp get() = contentH + rowVPad * 2
|
||||
|
||||
/**
|
||||
* Resolves the stripe height against the user's system font scale: the stripe
|
||||
* is a [Dp] while the lines beside it are `sp`, so without this the text
|
||||
* outgrows it at large font settings.
|
||||
* Resolves the row content height and pill width against the user's system font
|
||||
* scale: both are [Dp] while the text in and beside them is `sp`, so without
|
||||
* this the text outgrows them at large font settings.
|
||||
*/
|
||||
fun scaledForFont(fontScale: Float): AgendaMetrics =
|
||||
if (fontScale == 1f) this else copy(stripeH = stripeH * fontScale)
|
||||
if (fontScale == 1f) this else copy(contentH = contentH * fontScale, pillWidth = pillWidth * fontScale)
|
||||
}
|
||||
|
||||
/*
|
||||
* Type sizes are anchored to the Material 3 type scale, with two deviations:
|
||||
* Type sizes are anchored to the Material 3 type scale, with one deviation:
|
||||
*
|
||||
* ‡ SMALL's 13sp day header is off-scale, held there so SMALL reproduces the
|
||||
* widget's original constants verbatim (#51).
|
||||
* † Above Title Medium the M3 scale jumps 16 → 22 → 24, too coarse for four
|
||||
* steps; interpolated values are used where a role would force a ≥1.4x jump.
|
||||
*/
|
||||
|
||||
private val SMALL_METRICS = AgendaMetrics(
|
||||
title = 16.sp, // M3 Title Medium
|
||||
dayHeader = 13.sp, // ‡ off-scale, holds the #51 baseline
|
||||
pillWeekday = 11.sp,
|
||||
pillDay = 16.sp,
|
||||
eventTitle = 14.sp, // M3 Body Medium
|
||||
eventTime = 12.sp, // M3 Body Small
|
||||
placeholder = 14.sp, // M3 Body Medium
|
||||
message = 14.sp, // M3 Body Medium
|
||||
stripeH = 36.dp,
|
||||
iconImage = 22.dp,
|
||||
iconBox = 40.dp,
|
||||
contentH = 36.dp,
|
||||
pillWidth = 40.dp,
|
||||
rowVPad = 4.dp,
|
||||
dayHeaderTopPad = 10.dp,
|
||||
dayGap = 8.dp,
|
||||
monthLabel = 12.sp,
|
||||
)
|
||||
|
||||
private val MEDIUM_METRICS = AgendaMetrics(
|
||||
title = 18.sp, // †
|
||||
dayHeader = 14.sp, // M3 Title Small
|
||||
pillWeekday = 12.sp,
|
||||
pillDay = 18.sp,
|
||||
eventTitle = 16.sp, // M3 Body Large
|
||||
eventTime = 14.sp, // M3 Body Medium
|
||||
placeholder = 16.sp, // M3 Body Large
|
||||
message = 16.sp, // M3 Body Large
|
||||
stripeH = 40.dp,
|
||||
iconImage = 24.dp,
|
||||
iconBox = 44.dp,
|
||||
contentH = 40.dp,
|
||||
pillWidth = 44.dp,
|
||||
rowVPad = 5.dp,
|
||||
dayHeaderTopPad = 11.dp,
|
||||
dayGap = 9.dp,
|
||||
monthLabel = 14.sp,
|
||||
)
|
||||
|
||||
private val LARGE_METRICS = AgendaMetrics(
|
||||
title = 20.sp, // †
|
||||
dayHeader = 16.sp, // M3 Title Medium
|
||||
pillWeekday = 12.sp,
|
||||
pillDay = 20.sp,
|
||||
eventTitle = 18.sp, // †
|
||||
eventTime = 14.sp, // M3 Body Medium — the secondary line steps more slowly
|
||||
placeholder = 18.sp, // † on purpose, so the title keeps its
|
||||
message = 18.sp, // † lead and the hierarchy survives.
|
||||
stripeH = 46.dp,
|
||||
iconImage = 26.dp,
|
||||
iconBox = 48.dp,
|
||||
contentH = 46.dp,
|
||||
pillWidth = 48.dp,
|
||||
rowVPad = 6.dp,
|
||||
dayHeaderTopPad = 12.dp,
|
||||
dayGap = 10.dp,
|
||||
monthLabel = 14.sp,
|
||||
)
|
||||
|
||||
private val EXTRA_LARGE_METRICS = AgendaMetrics(
|
||||
title = 22.sp, // M3 Title Large
|
||||
dayHeader = 18.sp, // †
|
||||
pillWeekday = 14.sp,
|
||||
pillDay = 22.sp,
|
||||
eventTitle = 20.sp, // †
|
||||
eventTime = 16.sp, // M3 Body Large
|
||||
placeholder = 20.sp, // †
|
||||
message = 20.sp, // †
|
||||
stripeH = 52.dp,
|
||||
iconImage = 28.dp,
|
||||
iconBox = 52.dp,
|
||||
contentH = 52.dp,
|
||||
pillWidth = 52.dp,
|
||||
rowVPad = 8.dp,
|
||||
dayHeaderTopPad = 14.dp,
|
||||
dayGap = 12.dp,
|
||||
monthLabel = 16.sp,
|
||||
)
|
||||
|
||||
/** Indexed by [WidgetSize.ordinal] so lookup allocates nothing per recomposition. */
|
||||
|
||||
@@ -6,18 +6,13 @@ import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.glance.ColorFilter
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.Image
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.action.ActionParameters
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.appwidget.action.ActionCallback
|
||||
import androidx.glance.appwidget.action.actionRunCallback
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.lazy.LazyColumn
|
||||
@@ -36,6 +31,12 @@ import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.size
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.semantics.contentDescription
|
||||
import androidx.glance.semantics.semantics
|
||||
import androidx.glance.text.TextAlign
|
||||
import androidx.glance.unit.ColorProvider
|
||||
import de.jeanlucmakiola.floret.glance.WidgetHeader
|
||||
import de.jeanlucmakiola.floret.glance.WidgetHeaderAction
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
@@ -49,10 +50,12 @@ import de.jeanlucmakiola.calendula.ui.agenda.agendaTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
|
||||
@@ -72,20 +75,20 @@ import androidx.glance.currentState
|
||||
import de.jeanlucmakiola.floret.glance.WidgetData
|
||||
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
|
||||
import de.jeanlucmakiola.floret.glance.refreshStamp
|
||||
import de.jeanlucmakiola.floret.glance.updateWithRefresh
|
||||
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||
import de.jeanlucmakiola.calendula.widget.today
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toJavaDayOfWeek
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
|
||||
* of events grouped under day headers (the Google "Schedule" widget model).
|
||||
* of events, each day one card of grouped rows with its date pill beside it.
|
||||
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
|
||||
*/
|
||||
class AgendaWidget : GlanceAppWidget() {
|
||||
@@ -131,15 +134,8 @@ internal fun AgendaWidgetContent(
|
||||
}
|
||||
}
|
||||
|
||||
/** Rows a preview draws: more than any widget shows at once. */
|
||||
private const val PREVIEW_ROWS = 16
|
||||
|
||||
/** Reloads and redraws the tapped widget (header refresh button). */
|
||||
class RefreshAgendaAction : ActionCallback {
|
||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||
AgendaWidget().updateWithRefresh(context.applicationContext, glanceId)
|
||||
}
|
||||
}
|
||||
/** Rows a preview draws: Glance's cap on a Column's children, still more than a placed widget shows. */
|
||||
private const val PREVIEW_ROWS = 10
|
||||
|
||||
/**
|
||||
* Upper bound on rows handed to the [LazyColumn], so the serialized RemoteViews
|
||||
@@ -150,19 +146,18 @@ class RefreshAgendaAction : ActionCallback {
|
||||
*/
|
||||
private const val MAX_AGENDA_ROWS = 100
|
||||
|
||||
/** Flat row model so the [LazyColumn] can mix day headers and events. */
|
||||
/** Flat row model: one [LazyColumn] item per event or placeholder, a day's first one carrying its date pill. */
|
||||
private sealed interface AgendaRow {
|
||||
val date: LocalDate
|
||||
|
||||
data class Header(override val date: LocalDate, val today: LocalDate) : AgendaRow
|
||||
data class Event(override val date: LocalDate, val event: EventInstance) : AgendaRow
|
||||
/** "Nothing left today" line under an anchored, event-less today (#35). */
|
||||
/** "Nothing left today" line for an anchored, event-less today (#35). */
|
||||
data class Placeholder(override val date: LocalDate) : AgendaRow
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaWidgetBody(data: AgendaWidgetData, preview: Boolean) {
|
||||
// The stripe is re-resolved against the system font scale so it tracks the
|
||||
// The row height is re-resolved against the system font scale so it tracks the
|
||||
// sp-sized text beside it instead of drifting at large accessibility settings.
|
||||
val size = (data as? AgendaWidgetData.Ready)?.textSize ?: WidgetSize.SMALL
|
||||
val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale
|
||||
@@ -172,8 +167,8 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, preview: Boolean) {
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
) {
|
||||
AgendaHeader(metrics)
|
||||
Spacer(GlanceModifier.height(4.dp))
|
||||
AgendaHeader()
|
||||
Spacer(GlanceModifier.height(6.dp))
|
||||
when (data) {
|
||||
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics)
|
||||
is AgendaWidgetData.Ready -> AgendaRows(data = data, metrics = metrics, preview = preview)
|
||||
@@ -212,33 +207,47 @@ private fun AgendaRows(
|
||||
} else {
|
||||
val rows = buildList {
|
||||
visibleDays.forEach { day ->
|
||||
add(AgendaRow.Header(day.date, data.today))
|
||||
if (day.events.isEmpty()) {
|
||||
add(AgendaRow.Placeholder(day.date))
|
||||
} else {
|
||||
day.events.forEach { add(AgendaRow.Event(day.date, it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
// Bound the payload, then drop a day header the cut left
|
||||
// stranded with nothing under it.
|
||||
.take(MAX_AGENDA_ROWS)
|
||||
.dropLastWhile { it is AgendaRow.Header }
|
||||
// On a transparent widget each day becomes one card of grouped rows,
|
||||
// so it stays readable over the wallpaper (#292).
|
||||
val positions = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) dayPositions(rows) else null
|
||||
}.take(MAX_AGENDA_ROWS)
|
||||
// Each day is one card of grouped rows (#71), with its date pill
|
||||
// beside the first row.
|
||||
val positions = dayPositions(rows)
|
||||
val cardColor = agendaCardColor()
|
||||
val item: @Composable (Int) -> Unit = { index ->
|
||||
val row = rows[index]
|
||||
if (positions != null) {
|
||||
WidgetGroupedRow(
|
||||
position = positions[index],
|
||||
modifier = if (row is AgendaRow.Header && index > 0) GlanceModifier.padding(top = 6.dp) else GlanceModifier,
|
||||
color = GlanceTheme.colors.widgetBackground,
|
||||
) {
|
||||
AgendaRowContent(row, data, metrics)
|
||||
val position = positions[index]
|
||||
val first = position == Position.Top || position == Position.Alone
|
||||
val dayRow: @Composable (GlanceModifier) -> Unit = { modifier ->
|
||||
Row(modifier = modifier.fillMaxWidth()) {
|
||||
Box(modifier = GlanceModifier.width(metrics.pillWidth)) {
|
||||
if (first) DatePill(row.date, data.today, cardColor, metrics)
|
||||
}
|
||||
Spacer(GlanceModifier.width(GUTTER_GAP))
|
||||
Box(modifier = GlanceModifier.defaultWeight()) {
|
||||
val rowColor = when (row) {
|
||||
is AgendaRow.Event -> row.fill(data).let { themedColor(day = it.day, night = it.night) }
|
||||
is AgendaRow.Placeholder -> cardColor
|
||||
}
|
||||
WidgetGroupedRow(position = position, color = rowColor) {
|
||||
AgendaRowContent(row, data, metrics)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val gap = GlanceModifier.padding(top = if (first && index > 0) metrics.dayGap else 0.dp)
|
||||
// The pills carry no month, so a new month gets its name above its first day.
|
||||
if (first && index > 0 && row.date.month != rows[index - 1].date.month) {
|
||||
Column(modifier = gap.fillMaxWidth()) {
|
||||
MonthLabel(row.date, data.today, metrics)
|
||||
dayRow(GlanceModifier)
|
||||
}
|
||||
} else {
|
||||
AgendaRowContent(row, data, metrics)
|
||||
dayRow(gap)
|
||||
}
|
||||
}
|
||||
if (preview) {
|
||||
@@ -267,156 +276,154 @@ private fun dayPositions(rows: List<AgendaRow>): List<Position> = buildList {
|
||||
@Composable
|
||||
private fun AgendaRowContent(row: AgendaRow, data: AgendaWidgetData.Ready, metrics: AgendaMetrics) {
|
||||
when (row) {
|
||||
is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics)
|
||||
is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics)
|
||||
is AgendaRow.Event -> EventRow(
|
||||
event = row.event,
|
||||
day = row.date,
|
||||
soften = data.soften,
|
||||
fill = row.fill(data),
|
||||
is24Hour = data.is24Hour,
|
||||
dimmed = data.pastDisplay == PastEventDisplay.DIM && row.event.hasEnded(data.now),
|
||||
metrics = metrics,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaHeader(metrics: AgendaMetrics) {
|
||||
private fun AgendaHeader() {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth().wallpaperBacking().padding(horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
// Tap the "Upcoming" title to open the app on the user's default view
|
||||
// (issue #20) — a null target resolves to the default home in the host.
|
||||
WidgetHeader(
|
||||
title = context.getString(R.string.widget_agenda_title),
|
||||
titleAction = actionStartActivity(MainActivity.openViewIntent(context, view = null)),
|
||||
titleInset = 4.dp,
|
||||
) {
|
||||
// Tap the "Upcoming" title to open the app on the user's default view
|
||||
// (issue #20) — a null target resolves to the default home in the host.
|
||||
Text(
|
||||
text = context.getString(R.string.widget_agenda_title),
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.primary,
|
||||
fontSize = metrics.title,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
modifier = GlanceModifier
|
||||
.defaultWeight()
|
||||
.clickable(
|
||||
actionStartActivity(MainActivity.openViewIntent(context, view = null)),
|
||||
),
|
||||
)
|
||||
IconButton(
|
||||
resId = R.drawable.ic_widget_refresh,
|
||||
contentDescription = context.getString(R.string.widget_refresh),
|
||||
onClick = GlanceModifier.clickable(actionRunCallback<RefreshAgendaAction>()),
|
||||
metrics = metrics,
|
||||
)
|
||||
IconButton(
|
||||
resId = R.drawable.ic_widget_add,
|
||||
WidgetHeaderAction(
|
||||
icon = ImageProvider(R.drawable.ic_widget_add),
|
||||
contentDescription = context.getString(R.string.widget_new_event),
|
||||
onClick = GlanceModifier.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openCreateIntent(context, today(systemZone())),
|
||||
),
|
||||
),
|
||||
metrics = metrics,
|
||||
action = actionStartActivity(MainActivity.openCreateIntent(context, today(systemZone()))),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Cards sit one tone off the root fill, and stay opaque over a wallpaper (#292). */
|
||||
@Composable
|
||||
private fun IconButton(
|
||||
resId: Int,
|
||||
contentDescription: String,
|
||||
onClick: GlanceModifier,
|
||||
metrics: AgendaMetrics,
|
||||
) {
|
||||
Box(
|
||||
modifier = GlanceModifier.size(metrics.iconBox).then(onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
provider = ImageProvider(resId),
|
||||
contentDescription = contentDescription,
|
||||
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
|
||||
modifier = GlanceModifier.size(metrics.iconImage),
|
||||
)
|
||||
}
|
||||
private fun agendaCardColor(): ColorProvider = when (LocalWidgetBackground.current) {
|
||||
WidgetBackground.SOLID -> GlanceTheme.colors.surfaceVariant
|
||||
WidgetBackground.TINTED, WidgetBackground.TRANSPARENT -> GlanceTheme.colors.widgetBackground
|
||||
}
|
||||
|
||||
/** The month name ("November", with its year outside the current one) above a new month's first day. */
|
||||
@Composable
|
||||
private fun DayHeaderRow(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
private fun MonthLabel(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) {
|
||||
Text(
|
||||
text = agendaDayLabel(context, date, today),
|
||||
text = monthName(date, today),
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = if (date == today) GlanceTheme.colors.primary
|
||||
else GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = metrics.dayHeader,
|
||||
color = GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = metrics.monthLabel,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = 8.dp,
|
||||
end = 8.dp,
|
||||
top = metrics.dayHeaderTopPad,
|
||||
bottom = metrics.rowVPad,
|
||||
)
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
|
||||
),
|
||||
),
|
||||
.padding(bottom = 4.dp)
|
||||
.wallpaperBacking(radius = 8.dp)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* "No more events today" under an anchored, event-less today (#35). Kept plain —
|
||||
* a muted line indented to the event titles — since the widget's event rows are
|
||||
* plain too (a stripe + text, not cards), so a card here would look out of place.
|
||||
*/
|
||||
/** A day's weekday and number in a rounded pill, filled for today; opens that day in the app. */
|
||||
@Composable
|
||||
private fun DatePill(date: LocalDate, today: LocalDate, cardColor: ColorProvider, metrics: AgendaMetrics) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
val isToday = date == today
|
||||
val container = if (isToday) GlanceTheme.colors.primary else cardColor
|
||||
val content = if (isToday) GlanceTheme.colors.onPrimary else GlanceTheme.colors.onSurface
|
||||
val label = agendaDayLabel(context, date, today)
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.width(metrics.pillWidth)
|
||||
.height(metrics.rowHeight)
|
||||
.cornerRadius(metrics.pillWidth / 2)
|
||||
.background(container)
|
||||
.clickable(actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Agenda)))
|
||||
.semantics { contentDescription = label },
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = weekdayShort(date),
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = content, fontSize = metrics.pillWeekday, textAlign = TextAlign.Center),
|
||||
)
|
||||
Text(
|
||||
text = date.day.toString(),
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = content,
|
||||
fontSize = metrics.pillDay,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** "No more events today" in an anchored, event-less today's card (#35), as tall as the pill beside it. */
|
||||
@Composable
|
||||
private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
Text(
|
||||
text = context.getString(R.string.agenda_no_more_today),
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder),
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = TEXT_INDENT,
|
||||
end = 8.dp,
|
||||
top = 2.dp,
|
||||
bottom = metrics.rowVPad + 2.dp,
|
||||
)
|
||||
.height(metrics.rowHeight)
|
||||
.padding(horizontal = ROW_H_PAD)
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
|
||||
),
|
||||
),
|
||||
)
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Text(
|
||||
text = context.getString(R.string.agenda_no_more_today),
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** An event row's light and dark fill: its calendar's container tone, faded when the row is dimmed. */
|
||||
private data class EventFill(val day: Color, val night: Color)
|
||||
|
||||
private fun AgendaRow.Event.fill(data: AgendaWidgetData.Ready): EventFill {
|
||||
val dimmed = data.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now)
|
||||
fun tone(dark: Boolean) = eventFill(event.color, dark, data.soften).let {
|
||||
if (dimmed) it.copy(alpha = EventDimAlpha) else it
|
||||
}
|
||||
return EventFill(day = tone(dark = false), night = tone(dark = true))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventRow(
|
||||
event: EventInstance,
|
||||
day: LocalDate,
|
||||
soften: Boolean,
|
||||
fill: EventFill,
|
||||
is24Hour: Boolean,
|
||||
dimmed: Boolean,
|
||||
metrics: AgendaMetrics,
|
||||
) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
// Glance has no generic alpha modifier, so dim by fading the colour stripe and
|
||||
// dropping both text lines to the lower-emphasis on-surface-variant tone.
|
||||
fun stripe(dark: Boolean) = eventAccent(event.color, dark, soften).let {
|
||||
if (dimmed) it.copy(alpha = EventDimAlpha) else it
|
||||
}
|
||||
val stripeColor = themedColor(day = stripe(dark = false), night = stripe(dark = true))
|
||||
val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface
|
||||
Row(
|
||||
// Ink is picked against the opaque fill, then carries the dim along with it.
|
||||
fun ink(fill: Color, alpha: Float) = eventInk(fill.copy(alpha = 1f), alpha * fill.alpha)
|
||||
val titleInk = themedColor(day = ink(fill.day, 1f), night = ink(fill.night, 1f))
|
||||
val timeInk = themedColor(
|
||||
day = ink(fill.day, SECONDARY_INK_ALPHA),
|
||||
night = ink(fill.night, SECONDARY_INK_ALPHA),
|
||||
)
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = ROW_H_PAD, vertical = metrics.rowVPad)
|
||||
.height(metrics.rowHeight)
|
||||
.padding(horizontal = ROW_H_PAD)
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openEventIntent(
|
||||
@@ -430,30 +437,21 @@ private fun EventRow(
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(STRIPE_WIDTH)
|
||||
.height(metrics.stripeH)
|
||||
.cornerRadius(3.dp)
|
||||
.background(stripeColor),
|
||||
) {}
|
||||
Spacer(GlanceModifier.width(STRIPE_GAP))
|
||||
Column(modifier = GlanceModifier.defaultWeight()) {
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = titleColor,
|
||||
fontSize = metrics.eventTitle,
|
||||
textDecoration = glanceDeclinedDecoration(event.isDeclined),
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = eventTimeSummary(context, event, day, is24Hour),
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.eventTime),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = titleInk,
|
||||
fontSize = metrics.eventTitle,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textDecoration = glanceDeclinedDecoration(event.isDeclined),
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = eventTimeSummary(context, event, day, is24Hour),
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = timeInk, fontSize = metrics.eventTime),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,6 +487,16 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
|
||||
return if (relative != null) "$relative · $formatted" else formatted
|
||||
}
|
||||
|
||||
private fun monthName(date: LocalDate, today: LocalDate): String = formatCalendarTitle(
|
||||
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, 1),
|
||||
locale = Locale.getDefault(),
|
||||
currentYear = today.year,
|
||||
skeleton = "LLLL",
|
||||
)
|
||||
|
||||
private fun weekdayShort(date: LocalDate): String =
|
||||
date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, Locale.getDefault())
|
||||
|
||||
private fun eventTimeSummary(
|
||||
context: Context,
|
||||
event: EventInstance,
|
||||
|
||||
@@ -1,43 +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 three sized clickable icon boxes, their sized
|
||||
* icons and the clickable title, the spacer, the grid's Column and the
|
||||
* weekday row of sized labels.
|
||||
*/
|
||||
val fixed = plain + plain + plain + 3 * (clickableSized + sized) + clickablePlain + 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)
|
||||
@@ -68,8 +36,11 @@ 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. */
|
||||
internal class WeekLayout(val week: MonthWeek, val laneCap: Int) {
|
||||
/**
|
||||
* One week of the grid seated with [laneCap] lanes. [showsOverflow] is off when
|
||||
* the week has no room for even the "+N" row.
|
||||
*/
|
||||
internal class WeekLayout(val week: MonthWeek, val laneCap: Int, val showsOverflow: Boolean = true) {
|
||||
private val seating: List<DaySeating> = week.days.indices.map { week.seating(it, laneCap) }
|
||||
|
||||
/** Lanes filled from the top; empty lanes below are not drawn. */
|
||||
@@ -89,57 +60,29 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Event lanes a week row [weekHeight] tall holds under its day number, held to
|
||||
* [MAX_LANES] so a tall widget's payload stays bounded (#214).
|
||||
*/
|
||||
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 lanesFitting(weekHeight: Dp): Int =
|
||||
((weekHeight.value - DAY_ROW_DP - LANES_TOP_DP) / LANE_ROW_DP).toInt().coerceIn(0, MAX_LANES)
|
||||
|
||||
/**
|
||||
* Seats each week in [lanes] lanes. A week whose events need more gives its
|
||||
* last lane to the "+N" row instead.
|
||||
*/
|
||||
internal fun fitWeeks(weeks: List<MonthWeek>, lanes: Int): List<WeekLayout> = weeks.map { week ->
|
||||
if (lanes == 0) return@map WeekLayout(week, laneCap = 0, showsOverflow = false)
|
||||
val needed = WeekLayout(week, UNCAPPED_LANES).usedLanes
|
||||
WeekLayout(week, if (needed <= lanes) lanes else lanes - 1)
|
||||
}
|
||||
|
||||
/** Lanes a week shows at most, however tall the widget. */
|
||||
internal const val MAX_LANES = 8
|
||||
|
||||
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,169 @@
|
||||
package de.jeanlucmakiola.calendula.widget.month
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.graphics.Typeface
|
||||
import android.text.SpannableString
|
||||
import android.text.Spanned
|
||||
import android.text.style.StrikethroughSpan
|
||||
import android.text.style.StyleSpan
|
||||
import android.view.View
|
||||
import android.widget.RemoteViews
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.RemoteColor
|
||||
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
|
||||
import de.jeanlucmakiola.floret.glance.remoteColor
|
||||
import de.jeanlucmakiola.floret.glance.setImageTint
|
||||
import de.jeanlucmakiola.floret.glance.setTextColor
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.Month
|
||||
|
||||
/** The theme colours the plain-RemoteViews grid draws with. */
|
||||
internal data class GridColors(
|
||||
val onSurface: RemoteColor,
|
||||
val onSurfaceVariant: RemoteColor,
|
||||
val primary: RemoteColor,
|
||||
val onPrimary: RemoteColor,
|
||||
val cell: RemoteColor,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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))
|
||||
// Each day its own rounded cell, as in the app's month grid. Days around
|
||||
// the month get a shade over their (opaque) cell, so they recede without
|
||||
// letting a wallpaper show through.
|
||||
week.setImageTint(context, CELL_IDS[col], colors.cell)
|
||||
val outside = date.month != currentMonth
|
||||
week.setViewVisibility(CELL_DIM_IDS[col], if (outside) View.VISIBLE else View.GONE)
|
||||
if (outside) {
|
||||
week.setImageTint(context, CELL_DIM_IDS[col], mode.remoteColor(OUTSIDE_SHADE_DAY, OUTSIDE_SHADE_NIGHT))
|
||||
}
|
||||
val dayId = DAY_IDS[col]
|
||||
val isToday = date == today
|
||||
week.setTextViewText(dayId, if (isToday) bold(date.day.toString()) else date.day.toString())
|
||||
week.setTextColor(context, dayId, 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.setImageTint(context, TODAY_IDS[col], 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.showsOverflow && 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.setTextColor(context, R.id.more, 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.setImageTint(context, R.id.bar_bg, mode.remoteColor(dayFill, nightFill))
|
||||
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
// A span, like Glance's own decoration: paint flags would drop the text's defaults.
|
||||
views.setTextViewText(R.id.bar_title, if (event.isDeclined) span(title, StrikethroughSpan()) else title)
|
||||
views.setTextColor(context, R.id.bar_title, mode.remoteColor(eventInk(dayFill), eventInk(nightFill)))
|
||||
return views
|
||||
}
|
||||
|
||||
private fun bold(text: String): CharSequence = span(text, StyleSpan(Typeface.BOLD))
|
||||
|
||||
private fun span(text: String, span: Any): CharSequence =
|
||||
SpannableString(text).apply { setSpan(span, 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) }
|
||||
|
||||
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)
|
||||
/** The shade over a day outside the month: a dark cell needs more of it to read as dimmed. */
|
||||
private val OUTSIDE_SHADE_DAY = Color.Black.copy(alpha = 0.18f)
|
||||
private val OUTSIDE_SHADE_NIGHT = Color.Black.copy(alpha = 0.50f)
|
||||
|
||||
private val CELL_DIM_IDS = intArrayOf(
|
||||
R.id.cell_dim_0, R.id.cell_dim_1, R.id.cell_dim_2, R.id.cell_dim_3, R.id.cell_dim_4, R.id.cell_dim_5, R.id.cell_dim_6,
|
||||
)
|
||||
private val CELL_IDS = intArrayOf(R.id.cell_0, R.id.cell_1, R.id.cell_2, R.id.cell_3, R.id.cell_4, R.id.cell_5, R.id.cell_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,
|
||||
)
|
||||
@@ -1,7 +1,5 @@
|
||||
package de.jeanlucmakiola.calendula.widget.month
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
@@ -13,34 +11,33 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.glance.ColorFilter
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
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
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.AndroidRemoteViews
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
|
||||
import androidx.glance.background
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.appwidget.action.ActionCallback
|
||||
import androidx.glance.appwidget.action.actionRunCallback
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.appwidget.state.getAppWidgetState
|
||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||
import androidx.glance.background
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxHeight
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.height
|
||||
@@ -48,24 +45,25 @@ import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.size
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import androidx.glance.text.FontWeight
|
||||
import de.jeanlucmakiola.floret.glance.LocalWidgetThemeMode
|
||||
import de.jeanlucmakiola.floret.glance.WidgetColorRole
|
||||
import de.jeanlucmakiola.floret.glance.remoteColor
|
||||
import de.jeanlucmakiola.floret.glance.WidgetActionStyle
|
||||
import de.jeanlucmakiola.floret.glance.WidgetHeader
|
||||
import de.jeanlucmakiola.floret.glance.WidgetHeaderAction
|
||||
import de.jeanlucmakiola.floret.glance.WidgetHeaderActionGap
|
||||
import de.jeanlucmakiola.floret.glance.WidgetHeaderTextAction
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextAlign
|
||||
import androidx.glance.text.TextStyle
|
||||
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.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
||||
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||
import de.jeanlucmakiola.floret.glance.themedColor
|
||||
import de.jeanlucmakiola.floret.glance.wallpaperBacking
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
||||
@@ -90,10 +88,16 @@ 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, the header's bottom padding and
|
||||
* spacer and the weekday pills' padding, then the header (its 28dp pills, or a title line in its transparent
|
||||
* pill when that's taller) and the weekday row, the text lines at [fontScale].
|
||||
*/
|
||||
private fun gridChromeHeight(fontScale: Float): Dp =
|
||||
21.dp + maxOf(28.dp, 19.dp * fontScale + 8.dp) + 15.dp * fontScale
|
||||
|
||||
private fun currentMonthIndex(zone: TimeZone): Int = monthIndexOf(today(zone))
|
||||
|
||||
private fun yearMonthOf(index: Int): YearMonth =
|
||||
@@ -104,9 +108,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.
|
||||
*
|
||||
@@ -117,7 +121,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() {
|
||||
|
||||
@@ -127,8 +131,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
|
||||
|
||||
@@ -229,7 +233,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) {
|
||||
) {
|
||||
when (source) {
|
||||
MonthWidgetSource.NeedsPermission -> {
|
||||
MonthHeader(label = "Calendula")
|
||||
MonthHeader(label = "Calendula", today = today(systemZone()))
|
||||
PermissionMessage()
|
||||
}
|
||||
is MonthWidgetSource.Ready -> {
|
||||
@@ -238,22 +242,30 @@ 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 context = LocalContext.current
|
||||
val fontScale = context.resources.configuration.fontScale
|
||||
val weekHeight = (LocalSize.current.height - gridChromeHeight(fontScale)) / weeks.size
|
||||
val layouts = fitWeeks(weeks, lanesFitting(weekHeight))
|
||||
val mode = LocalWidgetThemeMode.current
|
||||
val colors = GridColors(
|
||||
onSurface = remoteColor(WidgetColorRole.ON_SURFACE),
|
||||
onSurfaceVariant = remoteColor(WidgetColorRole.ON_SURFACE_VARIANT),
|
||||
primary = remoteColor(WidgetColorRole.PRIMARY),
|
||||
onPrimary = remoteColor(WidgetColorRole.ON_PRIMARY),
|
||||
cell = remoteColor(monthCellRole()),
|
||||
)
|
||||
|
||||
MonthHeader(label = monthLabel(ym, source.today.year))
|
||||
Spacer(GlanceModifier.height(2.dp))
|
||||
// One backing for the whole grid on a transparent widget (#292).
|
||||
Column(modifier = GlanceModifier.fillMaxWidth().defaultWeight().wallpaperBacking()) {
|
||||
MonthHeader(label = monthLabel(ym, source.today.year), today = source.today)
|
||||
Spacer(GlanceModifier.height(4.dp))
|
||||
// No backing behind the grid: the weekday pills and day cells
|
||||
// each carry their own fill, so a transparent widget stays
|
||||
// readable without one blob over the wallpaper (#292).
|
||||
Column(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) {
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -261,89 +273,79 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonthHeader(label: String) {
|
||||
private fun MonthHeader(label: String, today: LocalDate) {
|
||||
val context = LocalContext.current
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth().wallpaperBacking(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
// Tap the month/year title to open the app on the month view (issue #18),
|
||||
// rooted over the default home so backing out returns there.
|
||||
WidgetHeader(
|
||||
title = label,
|
||||
titleAction = actionStartActivity(MainActivity.openViewIntent(context, CalendarView.Month)),
|
||||
modifier = GlanceModifier.padding(bottom = 2.dp),
|
||||
titleInset = 4.dp,
|
||||
) {
|
||||
HeaderIcon(
|
||||
resId = R.drawable.ic_widget_chevron_left,
|
||||
WidgetHeaderAction(
|
||||
icon = ImageProvider(R.drawable.ic_widget_chevron_left),
|
||||
contentDescription = context.getString(R.string.widget_prev_month),
|
||||
onClick = GlanceModifier.clickable(
|
||||
actionRunCallback<ShiftMonthAction>(
|
||||
actionParametersOf(ShiftMonthAction.deltaKey to -1),
|
||||
),
|
||||
),
|
||||
action = actionRunCallback<ShiftMonthAction>(actionParametersOf(ShiftMonthAction.deltaKey to -1)),
|
||||
style = WidgetActionStyle.TONAL,
|
||||
)
|
||||
// Tap the month/year title to open the app on the month view (issue #18),
|
||||
// rooted over the default home so backing out returns there.
|
||||
Text(
|
||||
text = label,
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.primary,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
modifier = GlanceModifier
|
||||
.defaultWeight()
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openViewIntent(context, CalendarView.Month),
|
||||
),
|
||||
),
|
||||
)
|
||||
// The "today" button snaps the grid back to the current month in place.
|
||||
HeaderIcon(
|
||||
resId = R.drawable.ic_widget_today,
|
||||
WidgetHeaderActionGap()
|
||||
// The "today" pill carries today's number like the app icon, and snaps
|
||||
// the grid back to the current month in place.
|
||||
WidgetHeaderTextAction(
|
||||
text = today.day.toString(),
|
||||
contentDescription = context.getString(R.string.widget_today),
|
||||
onClick = GlanceModifier.clickable(actionRunCallback<ResetMonthAction>()),
|
||||
action = actionRunCallback<ResetMonthAction>(),
|
||||
style = WidgetActionStyle.TONAL,
|
||||
)
|
||||
HeaderIcon(
|
||||
resId = R.drawable.ic_widget_chevron_right,
|
||||
WidgetHeaderActionGap()
|
||||
WidgetHeaderAction(
|
||||
icon = ImageProvider(R.drawable.ic_widget_chevron_right),
|
||||
contentDescription = context.getString(R.string.widget_next_month),
|
||||
onClick = GlanceModifier.clickable(
|
||||
actionRunCallback<ShiftMonthAction>(
|
||||
actionParametersOf(ShiftMonthAction.deltaKey to 1),
|
||||
),
|
||||
),
|
||||
action = actionRunCallback<ShiftMonthAction>(actionParametersOf(ShiftMonthAction.deltaKey to 1)),
|
||||
style = WidgetActionStyle.TONAL,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeaderIcon(resId: Int, contentDescription: String, onClick: GlanceModifier) {
|
||||
Box(
|
||||
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(20.dp),
|
||||
WidgetHeaderActionGap()
|
||||
WidgetHeaderAction(
|
||||
icon = ImageProvider(R.drawable.ic_widget_add),
|
||||
contentDescription = context.getString(R.string.widget_new_event),
|
||||
action = actionStartActivity(MainActivity.openCreateIntent(context, today)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekdayHeader(weekStart: DayOfWeek, colW: Dp) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 1.dp)) {
|
||||
weekdayNarrowNames(weekStart).forEach { name ->
|
||||
Text(
|
||||
text = name,
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = 11.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
modifier = GlanceModifier.width(colW),
|
||||
)
|
||||
Box(modifier = GlanceModifier.width(colW).padding(horizontal = 1.dp)) {
|
||||
Text(
|
||||
text = name,
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.onPrimaryContainer,
|
||||
fontSize = 11.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.cornerRadius(8.dp)
|
||||
.background(GlanceTheme.colors.primaryContainer)
|
||||
.padding(vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The fill of a day cell: one tone off a filled root, the widget surface over a wallpaper. */
|
||||
private fun monthCellRole(background: WidgetBackground): WidgetColorRole = when (background) {
|
||||
WidgetBackground.SOLID -> WidgetColorRole.SURFACE_VARIANT
|
||||
WidgetBackground.TINTED, WidgetBackground.TRANSPARENT -> WidgetColorRole.WIDGET_BACKGROUND
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun monthCellRole(): WidgetColorRole = monthCellRole(LocalWidgetBackground.current)
|
||||
|
||||
/** Narrow weekday initials starting at [weekStart], in the device locale.
|
||||
* Computed outside the composable so the locale read stays observable-safe. */
|
||||
private fun weekdayNarrowNames(weekStart: DayOfWeek): List<String> {
|
||||
@@ -354,176 +356,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
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp"
|
||||
android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="@android:color/white"
|
||||
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
|
||||
</vector>
|
||||
@@ -1,6 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp"
|
||||
android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="@android:color/white"
|
||||
android:pathData="M19,3h-1V1h-2v2H8V1H6v2H5C3.89,3 3.01,3.9 3.01,5L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2zM19,19H5V8h14v11zM7,10h5v5H7z"/>
|
||||
</vector>
|
||||
@@ -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,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<corners android:radius="8dp" />
|
||||
<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,360 @@
|
||||
<?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"
|
||||
android:foreground="?android:attr/selectableItemBackground"
|
||||
android:padding="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_0"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_dim_0"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:id="@+id/strip_1"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:foreground="?android:attr/selectableItemBackground"
|
||||
android:padding="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_dim_1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:id="@+id/strip_2"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:foreground="?android:attr/selectableItemBackground"
|
||||
android:padding="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_2"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_dim_2"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:id="@+id/strip_3"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:foreground="?android:attr/selectableItemBackground"
|
||||
android:padding="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_3"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_dim_3"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:id="@+id/strip_4"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:foreground="?android:attr/selectableItemBackground"
|
||||
android:padding="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_4"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_dim_4"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:id="@+id/strip_5"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:foreground="?android:attr/selectableItemBackground"
|
||||
android:padding="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_5"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_dim_5"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:id="@+id/strip_6"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:foreground="?android:attr/selectableItemBackground"
|
||||
android:padding="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_6"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_dim_6"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
</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>
|
||||
@@ -15,25 +15,12 @@ class AgendaScaleTest {
|
||||
// SMALL is the default, so if this fails an agenda widget whose owner never
|
||||
// touched the size setting no longer looks as it did (#51).
|
||||
val m = metricsFor(WidgetSize.SMALL)
|
||||
assertThat(m.title).isEqualTo(16.sp)
|
||||
assertThat(m.dayHeader).isEqualTo(13.sp)
|
||||
assertThat(m.eventTitle).isEqualTo(14.sp)
|
||||
assertThat(m.eventTime).isEqualTo(12.sp)
|
||||
assertThat(m.placeholder).isEqualTo(14.sp)
|
||||
assertThat(m.message).isEqualTo(14.sp)
|
||||
assertThat(m.stripeH).isEqualTo(36.dp)
|
||||
assertThat(m.iconImage).isEqualTo(22.dp)
|
||||
assertThat(m.iconBox).isEqualTo(40.dp)
|
||||
assertThat(m.contentH).isEqualTo(36.dp)
|
||||
assertThat(m.rowVPad).isEqualTo(4.dp)
|
||||
assertThat(m.dayHeaderTopPad).isEqualTo(10.dp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the derived text indent matches the original hardcoded inset`() {
|
||||
// TEXT_INDENT replaced a hardcoded 19dp; it must still resolve to 19dp or
|
||||
// day headers and the placeholder line stop aligning with event titles.
|
||||
assertThat(TEXT_INDENT).isEqualTo(19.dp)
|
||||
assertThat(TEXT_INDENT).isEqualTo(ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP)
|
||||
}
|
||||
|
||||
// --- the ramp ------------------------------------------------------------
|
||||
@@ -43,17 +30,17 @@ class AgendaScaleTest {
|
||||
val steps = WidgetSize.entries.map(::metricsFor)
|
||||
|
||||
steps.zipWithNext { small, big ->
|
||||
assertThat(big.title.value).isAtLeast(small.title.value)
|
||||
assertThat(big.dayHeader.value).isAtLeast(small.dayHeader.value)
|
||||
assertThat(big.pillWeekday.value).isAtLeast(small.pillWeekday.value)
|
||||
assertThat(big.pillDay.value).isAtLeast(small.pillDay.value)
|
||||
assertThat(big.eventTitle.value).isAtLeast(small.eventTitle.value)
|
||||
assertThat(big.eventTime.value).isAtLeast(small.eventTime.value)
|
||||
assertThat(big.placeholder.value).isAtLeast(small.placeholder.value)
|
||||
assertThat(big.message.value).isAtLeast(small.message.value)
|
||||
assertThat(big.stripeH.value).isAtLeast(small.stripeH.value)
|
||||
assertThat(big.iconImage.value).isAtLeast(small.iconImage.value)
|
||||
assertThat(big.iconBox.value).isAtLeast(small.iconBox.value)
|
||||
assertThat(big.contentH.value).isAtLeast(small.contentH.value)
|
||||
assertThat(big.pillWidth.value).isAtLeast(small.pillWidth.value)
|
||||
assertThat(big.rowVPad.value).isAtLeast(small.rowVPad.value)
|
||||
assertThat(big.dayHeaderTopPad.value).isAtLeast(small.dayHeaderTopPad.value)
|
||||
assertThat(big.dayGap.value).isAtLeast(small.dayGap.value)
|
||||
assertThat(big.monthLabel.value).isAtLeast(small.monthLabel.value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,20 +58,28 @@ class AgendaScaleTest {
|
||||
// Guards against a future edit turning "more readable" into "absurd".
|
||||
val base = metricsFor(WidgetSize.SMALL)
|
||||
val top = metricsFor(WidgetSize.EXTRA_LARGE)
|
||||
assertThat(top.title.value / base.title.value).isLessThan(1.5f)
|
||||
assertThat(top.pillDay.value / base.pillDay.value).isLessThan(1.5f)
|
||||
assertThat(top.eventTitle.value / base.eventTitle.value).isLessThan(1.5f)
|
||||
}
|
||||
|
||||
// --- font scale ----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `the stripe tracks the system font scale`() {
|
||||
// The stripe is Dp, the text beside it is sp: without this the two diverge
|
||||
// at large accessibility font settings and the stripe under-runs the row.
|
||||
fun `the row height tracks the system font scale`() {
|
||||
// The row height is Dp, the text in it is sp: without this the two diverge
|
||||
// at large accessibility font settings and the text outgrows the row.
|
||||
val m = metricsFor(WidgetSize.SMALL)
|
||||
assertThat(m.scaledForFont(1f).stripeH).isEqualTo(36.dp)
|
||||
assertThat(m.scaledForFont(1.3f).stripeH.value).isWithin(0.01f).of(46.8f)
|
||||
assertThat(m.scaledForFont(0.85f).stripeH.value).isWithin(0.01f).of(30.6f)
|
||||
assertThat(m.scaledForFont(1f).contentH).isEqualTo(36.dp)
|
||||
assertThat(m.scaledForFont(1.3f).contentH.value).isWithin(0.01f).of(46.8f)
|
||||
assertThat(m.scaledForFont(0.85f).contentH.value).isWithin(0.01f).of(30.6f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the date pill widens with the system font scale`() {
|
||||
// The pill's text is sp; a fixed-width pill clips "Wed" / "23" at large font settings.
|
||||
val m = metricsFor(WidgetSize.SMALL)
|
||||
assertThat(m.scaledForFont(2f).pillWidth).isEqualTo(m.pillWidth * 2)
|
||||
assertThat(m.scaledForFont(2f).rowHeight).isEqualTo(m.contentH * 2 + m.rowVPad * 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -99,7 +94,7 @@ class AgendaScaleTest {
|
||||
// double-count it.
|
||||
val m = metricsFor(WidgetSize.MEDIUM)
|
||||
val scaled = m.scaledForFont(1.3f)
|
||||
assertThat(scaled.title).isEqualTo(m.title)
|
||||
assertThat(scaled.pillDay).isEqualTo(m.pillDay)
|
||||
assertThat(scaled.eventTitle).isEqualTo(m.eventTitle)
|
||||
assertThat(scaled.rowVPad).isEqualTo(m.rowVPad)
|
||||
}
|
||||
|
||||
@@ -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,37 @@ 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
|
||||
fun `lanes stop at the cap however tall the week`() {
|
||||
assertThat(lanesFitting(1000.dp)).isEqualTo(MAX_LANES)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a week with no room for a lane draws no overflow row either`() {
|
||||
val layouts = fitWeeks(busyWeeks(perDay = 5), lanes = 0)
|
||||
assertThat(layouts.none { it.showsOverflow }).isTrue()
|
||||
assertThat(layouts.all { it.lanes.isEmpty() }).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -83,7 +84,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()
|
||||
}
|
||||
|
||||
+1
-1
Submodule floret-kit updated: 6dc7bc50c5...f2ffaed925
Reference in New Issue
Block a user