Upcoming widget: today's date over the next events, rows fitted to the height (#437)
This commit is contained in:
@@ -388,6 +388,18 @@
|
||||
android:resource="@xml/appwidget_info_week_strip" />
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".widget.upcoming.UpcomingWidgetReceiver"
|
||||
android:label="@string/widget_upcoming_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_upcoming" />
|
||||
</receiver>
|
||||
|
||||
<!-- Keeps both widgets fresh: the calendar provider broadcasts
|
||||
PROVIDER_CHANGED on any data change (external sync; the app's own
|
||||
writes redraw directly). The day boundary arrives as the app's own
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
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.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.glance.WidgetGroupedRow
|
||||
import de.jeanlucmakiola.floret.glance.themedColor
|
||||
|
||||
/** Text and row sizes of a compact event list, from a widget's [WidgetSize]. */
|
||||
internal data class EventRowMetrics(val title: TextUnit, val time: TextUnit, val rowHeight: Dp, val message: TextUnit)
|
||||
|
||||
/** The agenda widget's sizes with tighter rows, resolved against the system font scale. */
|
||||
internal fun compactRowMetrics(size: WidgetSize, fontScale: Float): EventRowMetrics {
|
||||
val agenda = metricsFor(size)
|
||||
return EventRowMetrics(
|
||||
title = agenda.eventTitle,
|
||||
time = agenda.eventTime,
|
||||
rowHeight = agenda.contentH * fontScale + agenda.rowVPad,
|
||||
message = agenda.message,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One event as a two-line row in its calendar's fill, part of a grouped card
|
||||
* at [position]; opens the occurrence. [quiet] draws it in the card colour
|
||||
* instead, for events shown only as a look ahead.
|
||||
*/
|
||||
@Composable
|
||||
internal fun WidgetEventRow(
|
||||
event: EventInstance,
|
||||
secondary: String,
|
||||
soften: Boolean,
|
||||
metrics: EventRowMetrics,
|
||||
position: Position,
|
||||
source: CalendarView,
|
||||
dimmed: Boolean = false,
|
||||
quiet: Boolean = false,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
fun tone(dark: Boolean): Color = eventFill(event.color, dark, soften).let { if (dimmed) it.copy(alpha = EventDimAlpha) else it }
|
||||
val day = tone(dark = false)
|
||||
val night = tone(dark = true)
|
||||
// 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 card = widgetCardColor()
|
||||
WidgetGroupedRow(position = position, color = if (quiet) card else themedColor(day = day, night = night)) {
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.height(metrics.rowHeight)
|
||||
.padding(horizontal = 12.dp)
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openEventIntent(
|
||||
context = context,
|
||||
eventId = event.eventId,
|
||||
beginMillis = event.start.toEpochMilliseconds(),
|
||||
endMillis = event.end.toEpochMilliseconds(),
|
||||
source = source,
|
||||
),
|
||||
),
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val titleInk = if (quiet) {
|
||||
GlanceTheme.colors.onSurface
|
||||
} else {
|
||||
themedColor(day = ink(day, 1f), night = ink(night, 1f))
|
||||
}
|
||||
val timeInk = if (quiet) {
|
||||
GlanceTheme.colors.onSurfaceVariant
|
||||
} else {
|
||||
themedColor(day = ink(day, SECONDARY_INK_ALPHA), night = ink(night, SECONDARY_INK_ALPHA))
|
||||
}
|
||||
Text(
|
||||
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = titleInk,
|
||||
fontSize = metrics.title,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textDecoration = glanceDeclinedDecoration(event.isDeclined),
|
||||
),
|
||||
)
|
||||
Text(text = secondary, maxLines = 1, style = TextStyle(color = timeInk, fontSize = metrics.time))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import de.jeanlucmakiola.calendula.widget.date.DateWidget
|
||||
import de.jeanlucmakiola.calendula.widget.date.DateWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.week.WeekStripWidget
|
||||
import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidget
|
||||
import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetReceiver
|
||||
|
||||
/** A content option a widget's config screen offers, besides background and theme. */
|
||||
enum class WidgetOption { RANGE, TEXT_SIZE, PAST_DISPLAY, SHOW_TODAY, CALENDARS }
|
||||
@@ -61,6 +63,12 @@ enum class WidgetKind(
|
||||
setOf(WidgetOption.CALENDARS),
|
||||
DpSize(300.dp, 90.dp),
|
||||
),
|
||||
UPCOMING(
|
||||
UpcomingWidgetReceiver::class.java,
|
||||
R.string.widget_upcoming_label,
|
||||
setOf(WidgetOption.RANGE, WidgetOption.TEXT_SIZE, WidgetOption.CALENDARS),
|
||||
DpSize(180.dp, 180.dp),
|
||||
),
|
||||
;
|
||||
|
||||
fun widget(): GlanceAppWidget = when (this) {
|
||||
@@ -69,6 +77,7 @@ enum class WidgetKind(
|
||||
QUICK_ADD -> QuickAddWidget()
|
||||
DATE -> DateWidget()
|
||||
WEEK_STRIP -> WeekStripWidget()
|
||||
UPCOMING -> UpcomingWidget()
|
||||
}
|
||||
|
||||
/** Whether this kind stores [AgendaWidgetOptions] of its own. */
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.unit.ColorProvider
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.agendaTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toJavaDayOfWeek
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import java.util.Locale
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Cards sit one tone off the root fill, and stay opaque over a wallpaper (#292). */
|
||||
@Composable
|
||||
@@ -12,3 +27,29 @@ internal fun widgetCardColor(): ColorProvider = when (LocalWidgetBackground.curr
|
||||
WidgetBackground.SOLID -> GlanceTheme.colors.surfaceVariant
|
||||
WidgetBackground.TINTED, WidgetBackground.TRANSPARENT -> GlanceTheme.colors.widgetBackground
|
||||
}
|
||||
|
||||
/** An event's time on [day]: "All day", "Starts 14:00", "Ends 9:00" or "14:00 – 15:30". */
|
||||
internal fun widgetTimeLabel(context: Context, event: EventInstance, day: LocalDate, is24Hour: Boolean): String =
|
||||
when (val label = agendaTimeLabel(event, day, systemZone())) {
|
||||
AgendaTimeLabel.AllDay -> context.getString(R.string.event_detail_all_day)
|
||||
is AgendaTimeLabel.Starts -> context.getString(R.string.agenda_span_starts, formatWidgetTime(label.start, is24Hour))
|
||||
is AgendaTimeLabel.Ends -> context.getString(R.string.agenda_span_ends, formatWidgetTime(label.end, is24Hour))
|
||||
is AgendaTimeLabel.Range -> "${formatWidgetTime(label.start, is24Hour)} – ${formatWidgetTime(label.end, is24Hour)}"
|
||||
}
|
||||
|
||||
internal fun formatWidgetTime(instant: Instant, is24Hour: Boolean): String {
|
||||
val t = instant.toLocalDateTime(systemZone()).time
|
||||
return formatTimeOfDay(t.hour, t.minute, is24Hour, Locale.getDefault())
|
||||
}
|
||||
|
||||
/** "Today", "Tomorrow", or the short weekday within the coming week, else the short date. */
|
||||
internal fun relativeDayLabel(context: Context, date: LocalDate, today: LocalDate): String {
|
||||
val locale = Locale.getDefault()
|
||||
return when {
|
||||
date == today -> context.getString(R.string.agenda_header_today)
|
||||
date == today.plus(1, DateTimeUnit.DAY) -> context.getString(R.string.agenda_header_tomorrow)
|
||||
date < today.plus(7, DateTimeUnit.DAY) && date > today ->
|
||||
date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, locale)
|
||||
else -> localizedDateFormatter(locale, "dMMM").format(date.toJavaLocalDate())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,13 +45,10 @@ import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaTimeLabel
|
||||
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.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
@@ -61,12 +58,11 @@ import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.widgetCardColor
|
||||
import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
|
||||
import de.jeanlucmakiola.calendula.widget.loadAgendaWidget
|
||||
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetGroupedRow
|
||||
import de.jeanlucmakiola.floret.glance.themedColor
|
||||
import de.jeanlucmakiola.floret.glance.wallpaperBacking
|
||||
@@ -80,11 +76,8 @@ 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
|
||||
|
||||
/**
|
||||
@@ -464,8 +457,6 @@ private fun WidgetMessage(resId: Int, metrics: AgendaMetrics) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun zone(): TimeZone = systemZone()
|
||||
|
||||
/** "Today · Wed, 17 Jun" — relative word for today/tomorrow, else the date. */
|
||||
private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate): String {
|
||||
val relative = when (date) {
|
||||
@@ -497,20 +488,7 @@ private fun eventTimeSummary(
|
||||
day: LocalDate,
|
||||
is24Hour: Boolean,
|
||||
): String {
|
||||
val time = when (val label = agendaTimeLabel(event, day, zone())) {
|
||||
AgendaTimeLabel.AllDay -> context.getString(R.string.event_detail_all_day)
|
||||
is AgendaTimeLabel.Starts ->
|
||||
context.getString(R.string.agenda_span_starts, formatTime(label.start, is24Hour))
|
||||
is AgendaTimeLabel.Ends ->
|
||||
context.getString(R.string.agenda_span_ends, formatTime(label.end, is24Hour))
|
||||
is AgendaTimeLabel.Range ->
|
||||
"${formatTime(label.start, is24Hour)} – ${formatTime(label.end, is24Hour)}"
|
||||
}
|
||||
val time = widgetTimeLabel(context, event, day, is24Hour)
|
||||
val location = event.location?.takeIf { it.isNotBlank() }
|
||||
return if (location != null) "$time · $location" else time
|
||||
}
|
||||
|
||||
private fun formatTime(instant: Instant, is24Hour: Boolean): String {
|
||||
val t = instant.toLocalDateTime(zone()).time
|
||||
return formatTimeOfDay(t.hour, t.minute, is24Hour, Locale.getDefault())
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ import de.jeanlucmakiola.calendula.widget.month.MonthWidgetContent
|
||||
import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetContent
|
||||
import de.jeanlucmakiola.calendula.widget.date.DateWidgetContent
|
||||
import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetContent
|
||||
import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetContent
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||
import de.jeanlucmakiola.floret.components.GroupedChoiceRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||
@@ -151,6 +152,7 @@ internal fun WidgetConfigScreen(
|
||||
is WidgetPreviewData.Month ->
|
||||
MonthWidgetContent(preview.source, preview.index, state.appearance, state.appTheme)
|
||||
is WidgetPreviewData.Days -> when (state.kind) {
|
||||
WidgetKind.UPCOMING -> UpcomingWidgetContent(preview.data, state.appearance, state.appTheme)
|
||||
else -> WeekStripWidgetContent(preview.data, state.appearance, state.appTheme)
|
||||
}
|
||||
is WidgetPreviewData.Today -> if (state.kind == WidgetKind.DATE) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSettings
|
||||
import de.jeanlucmakiola.calendula.widget.loadDays
|
||||
import de.jeanlucmakiola.calendula.widget.weekOf
|
||||
import de.jeanlucmakiola.calendula.widget.upcoming.loadUpcoming
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetOption
|
||||
import de.jeanlucmakiola.calendula.widget.hiddenCalendars
|
||||
import de.jeanlucmakiola.calendula.widget.putHiddenCalendars
|
||||
@@ -237,6 +238,7 @@ class WidgetConfigViewModel @Inject constructor(
|
||||
WidgetPreviewData.Month(source.content, index)
|
||||
}
|
||||
WidgetKind.QUICK_ADD, WidgetKind.DATE -> WidgetPreviewData.Today(today(systemZone()))
|
||||
WidgetKind.UPCOMING -> WidgetPreviewData.Days(context.loadUpcoming(draft.loadContext(settings)).content)
|
||||
WidgetKind.WEEK_STRIP -> WidgetPreviewData.Days(context.loadDays(draft.loadContext(settings), ::weekOf).content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package de.jeanlucmakiola.calendula.widget.upcoming
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.LocalSize
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.provideContent
|
||||
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.fillMaxSize
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
|
||||
import de.jeanlucmakiola.calendula.widget.DaysWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetEventRow
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
|
||||
import de.jeanlucmakiola.calendula.widget.compactRowMetrics
|
||||
import de.jeanlucmakiola.calendula.widget.loadDays
|
||||
import de.jeanlucmakiola.calendula.widget.relativeDayLabel
|
||||
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.widgetLocale
|
||||
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
|
||||
import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetData
|
||||
import de.jeanlucmakiola.floret.glance.WidgetMessage
|
||||
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
|
||||
import de.jeanlucmakiola.floret.glance.refreshStamp
|
||||
import de.jeanlucmakiola.floret.glance.wallpaperBacking
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toJavaDayOfWeek
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
|
||||
/**
|
||||
* 2×2 upcoming (#437): today's date on top, then the next events across the
|
||||
* widget's range in compact rows, as many as its height holds. The date opens
|
||||
* the agenda, an event opens itself.
|
||||
*/
|
||||
class UpcomingWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
|
||||
// Exact: how many rows show follows the height.
|
||||
override val sizeMode = SizeMode.Exact
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val widgetData = WidgetData(context.loadUpcoming(context.widgetLoadContext(id)), refreshStamp(context, id))
|
||||
provideContent {
|
||||
val loaded = widgetData.current(currentRefreshStamp()) { context.loadUpcoming(context.widgetLoadContext(id)) }
|
||||
UpcomingWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class UpcomingWidgetReceiver : CalendulaWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = UpcomingWidget()
|
||||
}
|
||||
|
||||
internal suspend fun Context.loadUpcoming(load: WidgetLoadContext) = loadDays(load) { today, weekStart ->
|
||||
today..today.plus(load.options.range.dayCount(today, weekStart) - 1, DateTimeUnit.DAY)
|
||||
}
|
||||
|
||||
/** Most rows a tall widget lists; past this it is an agenda, which is its own widget. */
|
||||
private const val MAX_ROWS = 8
|
||||
|
||||
private val RowGap = 2.dp
|
||||
|
||||
/** Every event still to come or running, each once, on the first day it shows. */
|
||||
internal fun upcomingEvents(data: DaysWidgetData.Ready): List<Pair<LocalDate, EventInstance>> =
|
||||
data.days
|
||||
.flatMap { day -> day.events.map { day.date to it } }
|
||||
.filterNot { (_, event) -> event.hasEnded(data.now) }
|
||||
.distinctBy { (_, event) -> event.instanceId to event.start }
|
||||
|
||||
@Composable
|
||||
internal fun UpcomingWidgetContent(data: DaysWidgetData, appearance: WidgetAppearance, appTheme: ThemeMode) {
|
||||
CalendulaWidgetRoot(appearance, appTheme) {
|
||||
val context = LocalContext.current
|
||||
when (data) {
|
||||
DaysWidgetData.NeedsPermission -> WidgetMessage(context.getString(R.string.widget_needs_permission))
|
||||
is DaysWidgetData.Ready -> {
|
||||
val fontScale = context.resources.configuration.fontScale
|
||||
val metrics = compactRowMetrics(data.options.textSize, fontScale)
|
||||
val headerHeight = 44.dp * fontScale
|
||||
val room = LocalSize.current.height - 16.dp - headerHeight
|
||||
val fits = ((room + RowGap) / (metrics.rowHeight + RowGap)).toInt().coerceIn(1, MAX_ROWS)
|
||||
val events = upcomingEvents(data).take(fits)
|
||||
Column(modifier = GlanceModifier.fillMaxSize().padding(8.dp)) {
|
||||
DateHeader(data.today, headerHeight)
|
||||
if (events.isEmpty()) {
|
||||
WidgetMessage(context.getString(R.string.widget_upcoming_empty))
|
||||
} else {
|
||||
events.forEachIndexed { index, (day, event) ->
|
||||
val time = widgetTimeLabel(context, event, day, data.is24Hour)
|
||||
WidgetEventRow(
|
||||
event = event,
|
||||
secondary = if (day == data.today) time else "${relativeDayLabel(context, day, data.today)} · $time",
|
||||
soften = data.soften,
|
||||
metrics = metrics,
|
||||
position = positionOf(index, events.size),
|
||||
source = CalendarView.Agenda,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Today's number beside its weekday and month; opens the agenda. */
|
||||
@Composable
|
||||
private fun DateHeader(today: LocalDate, height: Dp) {
|
||||
val context = LocalContext.current
|
||||
val locale = widgetLocale()
|
||||
val backing = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
|
||||
GlanceModifier.wallpaperBacking(radius = 12.dp).padding(horizontal = 8.dp)
|
||||
} else {
|
||||
GlanceModifier.padding(horizontal = 4.dp)
|
||||
}
|
||||
Box(modifier = GlanceModifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) {
|
||||
Row(
|
||||
modifier = backing.clickable(actionStartActivity(MainActivity.openViewIntent(context, CalendarView.Agenda))),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = today.day.toString(),
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.primary, fontSize = 28.sp, fontWeight = FontWeight.Bold),
|
||||
)
|
||||
Spacer(GlanceModifier.width(8.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = today.dayOfWeek.toJavaDayOfWeek().getDisplayName(JavaTextStyle.FULL, locale),
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 13.sp, fontWeight = FontWeight.Medium),
|
||||
)
|
||||
Text(
|
||||
text = java.time.Month.of(today.month.ordinal + 1).getDisplayName(JavaTextStyle.FULL_STANDALONE, locale),
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/widget_preview_row" />
|
||||
<corners android:radius="10dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Static mock shown in the widget picker (android:previewLayout). -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/preview_widget_bg"
|
||||
android:orientation="vertical"
|
||||
android:padding="10dp"
|
||||
tools:ignore="HardcodedText">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="7 Wednesday"
|
||||
android:textColor="@color/widget_preview_primary"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView style="@style/WidgetPreviewEventRow" android:text="Stand-up\n9:30" />
|
||||
<TextView style="@style/WidgetPreviewEventRow" android:text="Lunch with Sam\n12:30" />
|
||||
<TextView style="@style/WidgetPreviewEventRow" android:text="Gym\nTomorrow · 18:00" />
|
||||
</LinearLayout>
|
||||
@@ -5,4 +5,5 @@
|
||||
<color name="widget_preview_variant">@android:color/system_neutral2_200</color>
|
||||
<color name="widget_preview_primary">@android:color/system_accent1_200</color>
|
||||
<color name="widget_preview_on_primary">@android:color/system_accent1_800</color>
|
||||
<color name="widget_preview_row">@android:color/system_accent2_800</color>
|
||||
</resources>
|
||||
|
||||
@@ -5,4 +5,5 @@
|
||||
<color name="widget_preview_variant">#A8ADB2</color>
|
||||
<color name="widget_preview_primary">#A3CBE2</color>
|
||||
<color name="widget_preview_on_primary">#003348</color>
|
||||
<color name="widget_preview_row">#2D3236</color>
|
||||
</resources>
|
||||
|
||||
@@ -5,4 +5,5 @@
|
||||
<color name="widget_preview_variant">@android:color/system_neutral2_700</color>
|
||||
<color name="widget_preview_primary">@android:color/system_accent1_600</color>
|
||||
<color name="widget_preview_on_primary">@android:color/system_accent1_0</color>
|
||||
<color name="widget_preview_row">@android:color/system_accent2_100</color>
|
||||
</resources>
|
||||
|
||||
@@ -443,6 +443,8 @@
|
||||
<item quantity="one">%1$s, %2$d event</item>
|
||||
<item quantity="other">%1$s, %2$d events</item>
|
||||
</plurals>
|
||||
<string name="widget_upcoming_label">Calendula upcoming</string>
|
||||
<string name="widget_upcoming_empty">No upcoming events</string>
|
||||
<string name="widget_refresh">Refresh</string>
|
||||
<string name="widget_new_event">New event</string>
|
||||
<string name="widget_needs_permission">Open Calendula to grant calendar access</string>
|
||||
|
||||
@@ -8,4 +8,5 @@
|
||||
<color name="widget_preview_variant">#6E7479</color>
|
||||
<color name="widget_preview_primary">#3B5364</color>
|
||||
<color name="widget_preview_on_primary">#FFFFFF</color>
|
||||
<color name="widget_preview_row">#E1E6EB</color>
|
||||
</resources>
|
||||
|
||||
@@ -10,4 +10,14 @@
|
||||
<item name="android:textColor">@color/widget_preview_on_surface</item>
|
||||
<item name="android:textSize">12sp</item>
|
||||
</style>
|
||||
<style name="WidgetPreviewEventRow">
|
||||
<item name="android:layout_width">match_parent</item>
|
||||
<item name="android:layout_height">wrap_content</item>
|
||||
<item name="android:layout_marginTop">3dp</item>
|
||||
<item name="android:background">@drawable/preview_widget_row_bg</item>
|
||||
<item name="android:paddingHorizontal">10dp</item>
|
||||
<item name="android:paddingVertical">3dp</item>
|
||||
<item name="android:textColor">@color/widget_preview_on_surface</item>
|
||||
<item name="android:textSize">11sp</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="110dp"
|
||||
android:minHeight="110dp"
|
||||
android:targetCellWidth="2"
|
||||
android:targetCellHeight="2"
|
||||
android:minResizeWidth="110dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:widgetCategory="home_screen"
|
||||
android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity"
|
||||
android:widgetFeatures="reconfigurable"
|
||||
android:description="@string/widget_upcoming_label"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:previewLayout="@layout/widget_preview_upcoming"
|
||||
android:initialLayout="@layout/glance_default_loading_layout" />
|
||||
@@ -0,0 +1,51 @@
|
||||
package de.jeanlucmakiola.calendula.widget.upcoming
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
|
||||
import de.jeanlucmakiola.calendula.widget.DaysWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Instant
|
||||
|
||||
class UpcomingEventsTest {
|
||||
|
||||
private val now = Instant.parse("2026-10-21T12:00:00Z")
|
||||
|
||||
private fun event(id: Long, start: Instant, end: Instant) = EventInstance(
|
||||
instanceId = id, eventId = id, calendarId = 1, title = "e$id",
|
||||
start = start, end = end, isAllDay = false, color = 0, location = null,
|
||||
)
|
||||
|
||||
private fun data(vararg days: AgendaDay) = DaysWidgetData.Ready(
|
||||
today = LocalDate(2026, 10, 21),
|
||||
firstDay = LocalDate(2026, 10, 21),
|
||||
lastDay = LocalDate(2026, 10, 27),
|
||||
days = days.toList(),
|
||||
weekStart = DayOfWeek.MONDAY,
|
||||
is24Hour = true,
|
||||
soften = true,
|
||||
options = AgendaWidgetOptions(AgendaRange.Week, WidgetSize.SMALL, true, PastEventDisplay.SHOW),
|
||||
now = now,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `ended events drop out and a multi-day one lists once, on its first day`() {
|
||||
val ended = event(1, now - 3.hours, now - 1.hours)
|
||||
val running = event(2, now - 1.hours, now + 30.hours)
|
||||
val later = event(3, now + 2.hours, now + 3.hours)
|
||||
val today = AgendaDay(LocalDate(2026, 10, 21), listOf(ended, running, later))
|
||||
val tomorrow = AgendaDay(LocalDate(2026, 10, 22), listOf(running))
|
||||
|
||||
val upcoming = upcomingEvents(data(today, tomorrow))
|
||||
|
||||
assertThat(upcoming.map { it.second.instanceId }).containsExactly(2L, 3L).inOrder()
|
||||
assertThat(upcoming.first().first).isEqualTo(LocalDate(2026, 10, 21))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user