Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6cec2f95e | ||
|
|
d012948de6 | ||
|
|
8ff8a2de1f | ||
|
|
3f81bb9ea5 | ||
|
|
a08ad15c4a | ||
|
|
c9cc40ff60 | ||
|
|
343a8904ea | ||
|
|
075940d29e | ||
|
|
670cdb226a | ||
|
|
a8da8bc7a3 | ||
|
|
cfcba283f1 | ||
|
|
85d4dc535c | ||
|
|
edf80fe8b8 | ||
|
|
0ecd6de27b |
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Six new home-screen widgets.** Quick add (1×1) opens a new event on today with the same defaults as the app's + button ([#435]). Date (1×1) shows today's weekday and day number on a rounded shape and opens today ([#376]). Week strip (4×1) shows this week's days with a dot in each event's colour and today filled ([#436]). Upcoming (2×2) shows today's date over your next events, as many as fit ([#437]). Today lists today's events; once they're over it says "Nothing left today" and shows tomorrow's first one or two ([#438]). Countdown counts down to your next timed event, or to an event or series you pick, like a holiday in 12 days ([#439]).
|
||||||
|
- **A month and day widget.** A compact month grid sits beside the events of one day. Tap a day in the grid to see its events, and tap the list to open that day in the app ([#441]).
|
||||||
|
- **Each widget has its own settings.** Placing a widget, or long-pressing it and choosing Edit, opens a settings screen with a live preview. Pick a solid, tinted or transparent background, a light or dark theme or the app's, and which calendars it shows. Widgets that list events also have their time range, text size and how past events look. Settings → Widgets lists the widgets on your home screen so you can change them from there. These options used to be shared by all widgets; widgets you already placed keep what you had set ([#292]).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **The agenda and month widgets have a new look.** The agenda puts each day in its own card, with a round date pill beside the first event and today's pill filled. The month widget has rounded day cells, weekday pills, shaded days outside the month and a + for a new event. The agenda's refresh button is gone, since widgets now update on their own ([#71], [#90]).
|
||||||
|
- **Tapping a day in the month widget opens that day,** wherever in its cell you tap ([#386]).
|
||||||
|
- **The widget picker shows what each widget looks like.** On Android 15 and later the previews are the real widgets with sample events, in your language and colours; on older versions they are redrawn to match.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Widgets show your changes straight away.** After you added, edited or deleted an event, a widget could keep showing the old events until something else redrew it ([#420], [#229]).
|
||||||
|
- **The month widget handles busy months.** It could show "Can't show content", or only one or two events per day. Each day now shows as many events as its height fits, and the rest fold into +N ([#372]).
|
||||||
|
|
||||||
## [2.23.0] — 2026-10-21
|
## [2.23.0] — 2026-10-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -1889,3 +1903,17 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#178]: https://codeberg.org/jlmakiola/calendula/issues/178
|
[#178]: https://codeberg.org/jlmakiola/calendula/issues/178
|
||||||
[#116]: https://codeberg.org/jlmakiola/calendula/issues/116
|
[#116]: https://codeberg.org/jlmakiola/calendula/issues/116
|
||||||
[#413]: https://codeberg.org/jlmakiola/calendula/issues/413
|
[#413]: https://codeberg.org/jlmakiola/calendula/issues/413
|
||||||
|
[#71]: https://codeberg.org/jlmakiola/calendula/issues/71
|
||||||
|
[#90]: https://codeberg.org/jlmakiola/calendula/issues/90
|
||||||
|
[#229]: https://codeberg.org/jlmakiola/calendula/issues/229
|
||||||
|
[#292]: https://codeberg.org/jlmakiola/calendula/issues/292
|
||||||
|
[#372]: https://codeberg.org/jlmakiola/calendula/issues/372
|
||||||
|
[#376]: https://codeberg.org/jlmakiola/calendula/issues/376
|
||||||
|
[#386]: https://codeberg.org/jlmakiola/calendula/issues/386
|
||||||
|
[#420]: https://codeberg.org/jlmakiola/calendula/issues/420
|
||||||
|
[#435]: https://codeberg.org/jlmakiola/calendula/issues/435
|
||||||
|
[#436]: https://codeberg.org/jlmakiola/calendula/issues/436
|
||||||
|
[#437]: https://codeberg.org/jlmakiola/calendula/issues/437
|
||||||
|
[#438]: https://codeberg.org/jlmakiola/calendula/issues/438
|
||||||
|
[#439]: https://codeberg.org/jlmakiola/calendula/issues/439
|
||||||
|
[#441]: https://codeberg.org/jlmakiola/calendula/issues/441
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ android {
|
|||||||
// (up to 2.22.3: 2.22.3 -> 22203; from 2.22.4: 2.23.0-beta.1 -> 2230001,
|
// (up to 2.22.3: 2.22.3 -> 22203; from 2.22.4: 2.23.0-beta.1 -> 2230001,
|
||||||
// 2.22.4 -> 2220499), and CI fails if the committed one doesn't match.
|
// 2.22.4 -> 2220499), and CI fails if the committed one doesn't match.
|
||||||
// See docs/RELEASING.md.
|
// See docs/RELEASING.md.
|
||||||
versionCode = 2230099
|
versionCode = 2240001
|
||||||
versionName = "2.23.0"
|
versionName = "2.24.0-beta.1"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -352,6 +352,95 @@
|
|||||||
android:resource="@xml/appwidget_info_month" />
|
android:resource="@xml/appwidget_info_month" />
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".widget.quickadd.QuickAddWidgetReceiver"
|
||||||
|
android:label="@string/widget_quick_add_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_quick_add" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".widget.date.DateWidgetReceiver"
|
||||||
|
android:label="@string/widget_date_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_date" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".widget.week.WeekStripWidgetReceiver"
|
||||||
|
android:label="@string/widget_week_strip_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_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>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".widget.today.TodayWidgetReceiver"
|
||||||
|
android:label="@string/widget_today_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_today" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".widget.countdown.CountdownWidgetReceiver"
|
||||||
|
android:label="@string/widget_countdown_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_countdown" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<!-- A countdown widget's redraw for when its label changes (#439). -->
|
||||||
|
<receiver
|
||||||
|
android:name=".widget.countdown.CountdownTickReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".widget.split.MonthDayWidgetReceiver"
|
||||||
|
android:label="@string/widget_month_day_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_month_day" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
<!-- Keeps both widgets fresh: the calendar provider broadcasts
|
<!-- Keeps both widgets fresh: the calendar provider broadcasts
|
||||||
PROVIDER_CHANGED on any data change (external sync; the app's own
|
PROVIDER_CHANGED on any data change (external sync; the app's own
|
||||||
writes redraw directly). The day boundary arrives as the app's own
|
writes redraw directly). The day boundary arrives as the app's own
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
|||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker
|
||||||
|
import de.jeanlucmakiola.calendula.widget.publishCalendulaWidgetPreviews
|
||||||
import de.jeanlucmakiola.calendula.widget.syncWidgetRollover
|
import de.jeanlucmakiola.calendula.widget.syncWidgetRollover
|
||||||
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
|
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
|
||||||
import de.jeanlucmakiola.floret.crash.CrashConfig
|
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||||
@@ -58,10 +59,12 @@ class CalendulaApp : Application() {
|
|||||||
* (#228). Idempotent, and it covers what no broadcast reaches — an alarm
|
* (#228). Idempotent, and it covers what no broadcast reaches — an alarm
|
||||||
* dropped by a force-stop is armed again the next time the app is opened.
|
* dropped by a force-stop is armed again the next time the app is opened.
|
||||||
* Off the main thread: a handful of binder calls on every process start.
|
* Off the main thread: a handful of binder calls on every process start.
|
||||||
|
* The picker previews are republished here too, once per update.
|
||||||
*/
|
*/
|
||||||
private fun reconcileWidgetRollover() {
|
private fun reconcileWidgetRollover() {
|
||||||
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||||
syncWidgetRollover(this@CalendulaApp)
|
syncWidgetRollover(this@CalendulaApp)
|
||||||
|
publishCalendulaWidgetPreviews(this@CalendulaApp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ import javax.inject.Singleton
|
|||||||
interface CalendarDataSource {
|
interface CalendarDataSource {
|
||||||
fun calendars(): List<CalendarSource>
|
fun calendars(): List<CalendarSource>
|
||||||
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
||||||
|
|
||||||
|
/** [instances] of event [eventId] alone, soonest first. */
|
||||||
|
fun eventInstances(eventId: Long, beginMillis: Long, endMillis: Long): List<EventInstance> =
|
||||||
|
instances(beginMillis, endMillis).filter { it.eventId == eventId }
|
||||||
/**
|
/**
|
||||||
* [allDayReminderTimeMinutes]: the hour all-day reminders fire at, needed to
|
* [allDayReminderTimeMinutes]: the hour all-day reminders fire at, needed to
|
||||||
* decode their stored offsets back to whole-day lead times (see
|
* decode their stored offsets back to whole-day lead times (see
|
||||||
@@ -563,21 +567,33 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
|
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
|
||||||
|
queryInstances(beginMillis, endMillis, null, null)
|
||||||
|
|
||||||
|
override fun eventInstances(eventId: Long, beginMillis: Long, endMillis: Long): List<EventInstance> =
|
||||||
|
queryInstances(beginMillis, endMillis, "${CalendarContract.Instances.EVENT_ID} = ?", arrayOf(eventId.toString()))
|
||||||
|
|
||||||
|
private fun queryInstances(
|
||||||
|
beginMillis: Long,
|
||||||
|
endMillis: Long,
|
||||||
|
extraSelection: String?,
|
||||||
|
args: Array<String>?,
|
||||||
|
): List<EventInstance> {
|
||||||
ensureObserversRegistered()
|
ensureObserversRegistered()
|
||||||
|
// Hide cancelled occurrences: "delete only this event" writes a
|
||||||
|
// cancelled exception for the one instance (#47). A NULL status is a
|
||||||
|
// normal, un-cancelled event, so it must survive the filter — a bare
|
||||||
|
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
|
||||||
|
// DELETED = 0: a provider that soft-deletes may keep the row's
|
||||||
|
// instances until a purge (#364).
|
||||||
|
val visible = "(${CalendarContract.Instances.STATUS} IS NULL OR " +
|
||||||
|
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}) AND " +
|
||||||
|
"${CalendarContract.Events.DELETED} = 0"
|
||||||
return resolver.query(
|
return resolver.query(
|
||||||
instancesUri(beginMillis, endMillis),
|
instancesUri(beginMillis, endMillis),
|
||||||
InstanceProjection.COLUMNS,
|
InstanceProjection.COLUMNS,
|
||||||
// Hide cancelled occurrences: "delete only this event" writes a
|
if (extraSelection == null) visible else "$visible AND $extraSelection",
|
||||||
// cancelled exception for the one instance (#47). A NULL status is a
|
args,
|
||||||
// normal, un-cancelled event, so it must survive the filter — a bare
|
|
||||||
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
|
|
||||||
// DELETED = 0: a provider that soft-deletes may keep the row's
|
|
||||||
// instances until a purge (#364).
|
|
||||||
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
|
|
||||||
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}) AND " +
|
|
||||||
"${CalendarContract.Events.DELETED} = 0",
|
|
||||||
null,
|
|
||||||
CalendarContract.Instances.BEGIN + " ASC",
|
CalendarContract.Instances.BEGIN + " ASC",
|
||||||
)?.use { c -> c.mapRowsNotNull { toEventInstance() } } ?: emptyList()
|
)?.use { c -> c.mapRowsNotNull { toEventInstance() } } ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ interface CalendarRepository {
|
|||||||
fun calendars(): Flow<List<CalendarSource>>
|
fun calendars(): Flow<List<CalendarSource>>
|
||||||
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
|
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
|
||||||
|
|
||||||
|
/** One read of event [eventId]'s occurrences overlapping [range], soonest first, whatever calendar it is in. */
|
||||||
|
suspend fun eventOccurrences(eventId: Long, range: ClosedRange<Instant>): List<EventInstance>
|
||||||
|
|
||||||
/** Re-query every open [calendars] / [instances] flow. */
|
/** Re-query every open [calendars] / [instances] flow. */
|
||||||
fun refresh()
|
fun refresh()
|
||||||
|
|
||||||
|
|||||||
@@ -194,6 +194,11 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
cachedCalendars
|
cachedCalendars
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun eventOccurrences(eventId: Long, range: ClosedRange<Instant>): List<EventInstance> =
|
||||||
|
withContext(io) {
|
||||||
|
dataSource.eventInstances(eventId, range.start.toEpochMillis(), range.endInclusive.toEpochMillis())
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
|
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
|
||||||
dataSource.eventDetail(eventId, allDayReminderTimeMinutes())
|
dataSource.eventDetail(eventId, allDayReminderTimeMinutes())
|
||||||
?: throw NoSuchEventException(eventId)
|
?: throw NoSuchEventException(eventId)
|
||||||
|
|||||||
@@ -101,9 +101,10 @@ fun TimeZonePickerDialog(
|
|||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
scrollable = false,
|
scrollable = false,
|
||||||
) {
|
) {
|
||||||
SearchField(
|
PickerSearchField(
|
||||||
query = query,
|
query = query,
|
||||||
onQueryChange = { query = it },
|
onQueryChange = { query = it },
|
||||||
|
placeholder = stringResource(R.string.event_edit_timezone_search),
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
)
|
)
|
||||||
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) {
|
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) {
|
||||||
@@ -181,9 +182,10 @@ fun TimeZonePickerDialog(
|
|||||||
* which has a top bar to put it in) because the picker's bar is the title.
|
* which has a top bar to put it in) because the picker's bar is the title.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun SearchField(
|
internal fun PickerSearchField(
|
||||||
query: String,
|
query: String,
|
||||||
onQueryChange: (String) -> Unit,
|
onQueryChange: (String) -> Unit,
|
||||||
|
placeholder: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val keyboard = LocalSoftwareKeyboardController.current
|
val keyboard = LocalSoftwareKeyboardController.current
|
||||||
@@ -206,7 +208,7 @@ private fun SearchField(
|
|||||||
InlineTextField(
|
InlineTextField(
|
||||||
value = query,
|
value = query,
|
||||||
onValueChange = onQueryChange,
|
onValueChange = onQueryChange,
|
||||||
placeholder = stringResource(R.string.event_edit_timezone_search),
|
placeholder = placeholder,
|
||||||
capitalization = KeyboardCapitalization.None,
|
capitalization = KeyboardCapitalization.None,
|
||||||
imeAction = ImeAction.Search,
|
imeAction = ImeAction.Search,
|
||||||
onImeAction = { keyboard?.hide() },
|
onImeAction = { keyboard?.hide() },
|
||||||
|
|||||||
@@ -29,11 +29,10 @@ import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
|||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.calendula.widget.config.PlacedWidget
|
import de.jeanlucmakiola.calendula.widget.config.PlacedWidget
|
||||||
import de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity
|
import de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity
|
||||||
import de.jeanlucmakiola.calendula.widget.config.WidgetKind
|
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import de.jeanlucmakiola.calendula.widget.config.backgroundLabel
|
import de.jeanlucmakiola.calendula.widget.config.backgroundLabel
|
||||||
import de.jeanlucmakiola.calendula.widget.config.label
|
|
||||||
import de.jeanlucmakiola.calendula.widget.config.placedWidgets
|
import de.jeanlucmakiola.calendula.widget.config.placedWidgets
|
||||||
import de.jeanlucmakiola.calendula.widget.config.themeLabel
|
import de.jeanlucmakiola.calendula.widget.config.themeLabel
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||||
|
import de.jeanlucmakiola.floret.glance.reloadOnUpdate
|
||||||
|
import de.jeanlucmakiola.floret.glance.restoreState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What every widget receiver shares: the midnight rollover follows whether any
|
||||||
|
* widget is placed (#228), a restore keeps each widget's settings (#292), and
|
||||||
|
* the system's `updatePeriodMillis` wake-up reloads the data and re-arms the
|
||||||
|
* alarm — anything that drops a pending alarm without a broadcast (force-stop,
|
||||||
|
* battery restriction, an OEM freeze) is repaired there rather than on the
|
||||||
|
* next app open.
|
||||||
|
*/
|
||||||
|
abstract class CalendulaWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
|
|
||||||
|
override fun onEnabled(context: Context) {
|
||||||
|
super.onEnabled(context)
|
||||||
|
syncWidgetRollover(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDisabled(context: Context) {
|
||||||
|
super.onDisabled(context)
|
||||||
|
syncWidgetRollover(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) {
|
||||||
|
restoreState(context, oldWidgetIds, newWidgetIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingSuperCall")
|
||||||
|
override fun onUpdate(
|
||||||
|
context: Context,
|
||||||
|
appWidgetManager: AppWidgetManager,
|
||||||
|
appWidgetIds: IntArray,
|
||||||
|
) {
|
||||||
|
// In place of super, which only recomposes and would take the goAsync handle.
|
||||||
|
reloadOnUpdate(context, appWidgetIds)
|
||||||
|
syncWidgetRollover(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,12 @@ import androidx.datastore.preferences.core.MutablePreferences
|
|||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||||
import androidx.glance.GlanceId
|
import androidx.glance.GlanceId
|
||||||
import androidx.glance.appwidget.state.getAppWidgetState
|
import androidx.glance.appwidget.state.getAppWidgetState
|
||||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||||
@@ -50,6 +52,7 @@ private val AgendaRangeKey = stringPreferencesKey("cfg_agenda_range")
|
|||||||
private val TextSizeKey = stringPreferencesKey("cfg_text_size")
|
private val TextSizeKey = stringPreferencesKey("cfg_text_size")
|
||||||
private val ShowTodayKey = booleanPreferencesKey("cfg_show_today")
|
private val ShowTodayKey = booleanPreferencesKey("cfg_show_today")
|
||||||
private val PastDisplayKey = stringPreferencesKey("cfg_past_display")
|
private val PastDisplayKey = stringPreferencesKey("cfg_past_display")
|
||||||
|
private val HiddenCalendarsKey = stringSetPreferencesKey("cfg_hidden_calendars")
|
||||||
|
|
||||||
internal val Preferences.widgetAppearance: WidgetAppearance
|
internal val Preferences.widgetAppearance: WidgetAppearance
|
||||||
get() = WidgetAppearance(
|
get() = WidgetAppearance(
|
||||||
@@ -84,5 +87,19 @@ internal fun MutablePreferences.putAgendaOptions(options: AgendaWidgetOptions) {
|
|||||||
this[PastDisplayKey] = options.pastDisplay.name
|
this[PastDisplayKey] = options.pastDisplay.name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calendars this widget leaves out, on top of the ones hidden app-wide. Stored
|
||||||
|
* as the excluded set, so a calendar added later shows up by default.
|
||||||
|
*/
|
||||||
|
internal val Preferences.hiddenCalendars: Set<Long>
|
||||||
|
get() = this[HiddenCalendarsKey]?.mapNotNull { it.toLongOrNull() }?.toSet().orEmpty()
|
||||||
|
|
||||||
|
internal fun MutablePreferences.putHiddenCalendars(ids: Set<Long>) {
|
||||||
|
this[HiddenCalendarsKey] = ids.map(Long::toString).toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun List<EventInstance>.withoutCalendars(hidden: Set<Long>): List<EventInstance> =
|
||||||
|
if (hidden.isEmpty()) this else filterNot { it.calendarId in hidden }
|
||||||
|
|
||||||
internal suspend fun Context.widgetState(id: GlanceId): Preferences =
|
internal suspend fun Context.widgetState(id: GlanceId): Preferences =
|
||||||
getAppWidgetState(this, PreferencesGlanceStateDefinition, id)
|
getAppWidgetState(this, PreferencesGlanceStateDefinition, id)
|
||||||
|
|||||||
@@ -96,6 +96,12 @@ internal val SettingsPrefs.widgetSettings: Flow<WidgetSettings>
|
|||||||
|
|
||||||
internal fun systemZone(): TimeZone = TimeZone.currentSystemDefault()
|
internal fun systemZone(): TimeZone = TimeZone.currentSystemDefault()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The locale widget text is written in. Glance has no LocalConfiguration, and a
|
||||||
|
* widget is redrawn on a locale change anyway (LOCALE_CHANGED), so a plain read is enough.
|
||||||
|
*/
|
||||||
|
internal fun widgetLocale(): Locale = Locale.getDefault()
|
||||||
|
|
||||||
internal fun today(zone: TimeZone): LocalDate =
|
internal fun today(zone: TimeZone): LocalDate =
|
||||||
Clock.System.now().toLocalDateTime(zone).date
|
Clock.System.now().toLocalDateTime(zone).date
|
||||||
|
|
||||||
@@ -145,8 +151,9 @@ sealed interface MonthWidgetSource {
|
|||||||
/** [loadAgendaWidgetData] with agenda widget [id]'s own options. */
|
/** [loadAgendaWidgetData] with agenda widget [id]'s own options. */
|
||||||
internal suspend fun Context.loadAgendaWidget(id: GlanceId): Themed<AgendaWidgetData> {
|
internal suspend fun Context.loadAgendaWidget(id: GlanceId): Themed<AgendaWidgetData> {
|
||||||
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
|
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
|
||||||
val options = widgetState(id).agendaWidgetOptions(settings.agendaDefaults)
|
val state = widgetState(id)
|
||||||
return Themed(loadAgendaWidgetData(settings, options), settings.themeMode)
|
val options = state.agendaWidgetOptions(settings.agendaDefaults)
|
||||||
|
return Themed(loadAgendaWidgetData(settings, options, state.hiddenCalendars), settings.themeMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -157,6 +164,7 @@ internal suspend fun Context.loadAgendaWidget(id: GlanceId): Themed<AgendaWidget
|
|||||||
internal suspend fun Context.loadAgendaWidgetData(
|
internal suspend fun Context.loadAgendaWidgetData(
|
||||||
settings: WidgetSettings,
|
settings: WidgetSettings,
|
||||||
options: AgendaWidgetOptions,
|
options: AgendaWidgetOptions,
|
||||||
|
hiddenCalendars: Set<Long>,
|
||||||
): AgendaWidgetData {
|
): AgendaWidgetData {
|
||||||
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
|
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
|
||||||
val zone = systemZone()
|
val zone = systemZone()
|
||||||
@@ -166,7 +174,7 @@ internal suspend fun Context.loadAgendaWidgetData(
|
|||||||
val dayCount = options.range.dayCount(anchor, weekStart)
|
val dayCount = options.range.dayCount(anchor, weekStart)
|
||||||
val window = agendaRange(anchor, dayCount - 1, zone)
|
val window = agendaRange(anchor, dayCount - 1, zone)
|
||||||
val windowEnd = anchor.plus(dayCount - 1, DateTimeUnit.DAY)
|
val windowEnd = anchor.plus(dayCount - 1, DateTimeUnit.DAY)
|
||||||
val instances = ep.calendarRepository().instances(window).first()
|
val instances = ep.calendarRepository().instances(window).first().withoutCalendars(hiddenCalendars)
|
||||||
return AgendaWidgetData.Ready(
|
return AgendaWidgetData.Ready(
|
||||||
today = anchor,
|
today = anchor,
|
||||||
days = groupAgendaDays(anchor, windowEnd, instances, zone),
|
days = groupAgendaDays(anchor, windowEnd, instances, zone),
|
||||||
@@ -214,15 +222,20 @@ private object MonthSourceCache {
|
|||||||
/** [loadThemedMonthSource] for widget [id], served from [MonthSourceCache] while its [stamp] holds. */
|
/** [loadThemedMonthSource] for widget [id], served from [MonthSourceCache] while its [stamp] holds. */
|
||||||
internal suspend fun Context.loadCachedMonthSource(id: GlanceId, centerIndex: Int, stamp: Long): Themed<MonthWidgetSource> =
|
internal suspend fun Context.loadCachedMonthSource(id: GlanceId, centerIndex: Int, stamp: Long): Themed<MonthWidgetSource> =
|
||||||
MonthSourceCache.get(id, centerIndex, stamp)
|
MonthSourceCache.get(id, centerIndex, stamp)
|
||||||
?: loadThemedMonthSource(centerIndex).also { MonthSourceCache.put(id, centerIndex, stamp, it) }
|
?: loadThemedMonthSource(centerIndex, widgetState(id).hiddenCalendars)
|
||||||
|
.also { MonthSourceCache.put(id, centerIndex, stamp, it) }
|
||||||
|
|
||||||
/** Read backing the month widget's grid for the window around [centerIndex], with the app theme. */
|
/** Read backing the month widget's grid for the window around [centerIndex], with the app theme. */
|
||||||
internal suspend fun Context.loadThemedMonthSource(centerIndex: Int): Themed<MonthWidgetSource> {
|
internal suspend fun Context.loadThemedMonthSource(centerIndex: Int, hiddenCalendars: Set<Long>): Themed<MonthWidgetSource> {
|
||||||
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
|
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
|
||||||
return Themed(queryMonthSource(centerIndex, settings), settings.themeMode)
|
return Themed(queryMonthSource(centerIndex, settings, hiddenCalendars), settings.themeMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun Context.queryMonthSource(centerIndex: Int, settings: WidgetSettings): MonthWidgetSource {
|
private suspend fun Context.queryMonthSource(
|
||||||
|
centerIndex: Int,
|
||||||
|
settings: WidgetSettings,
|
||||||
|
hiddenCalendars: Set<Long>,
|
||||||
|
): MonthWidgetSource {
|
||||||
if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission
|
if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission
|
||||||
val zone = systemZone()
|
val zone = systemZone()
|
||||||
val ep = widgetEntryPoint()
|
val ep = widgetEntryPoint()
|
||||||
@@ -239,7 +252,75 @@ private suspend fun Context.queryMonthSource(centerIndex: Int, settings: WidgetS
|
|||||||
today = today(zone),
|
today = today(zone),
|
||||||
weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()),
|
weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()),
|
||||||
soften = settings.soften,
|
soften = settings.soften,
|
||||||
instances = ep.calendarRepository().instances(from..to).first(),
|
instances = ep.calendarRepository().instances(from..to).first().withoutCalendars(hiddenCalendars),
|
||||||
months = months,
|
months = months,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Today and the app theme, for a widget that draws no events. */
|
||||||
|
internal suspend fun Context.loadToday(): Themed<LocalDate> =
|
||||||
|
Themed(today(systemZone()), widgetEntryPoint().settingsPrefs().widgetSettings.first().themeMode)
|
||||||
|
|
||||||
|
/** What a widget drawing a run of days loads: those days' events, grouped like the agenda. */
|
||||||
|
sealed interface DaysWidgetData {
|
||||||
|
data object NeedsPermission : DaysWidgetData
|
||||||
|
data class Ready(
|
||||||
|
val today: LocalDate,
|
||||||
|
val firstDay: LocalDate,
|
||||||
|
val lastDay: LocalDate,
|
||||||
|
/** Only the days with events, in order. */
|
||||||
|
val days: List<AgendaDay>,
|
||||||
|
val weekStart: DayOfWeek,
|
||||||
|
val is24Hour: Boolean,
|
||||||
|
val soften: Boolean,
|
||||||
|
val options: AgendaWidgetOptions,
|
||||||
|
val now: Instant,
|
||||||
|
) : DaysWidgetData {
|
||||||
|
fun eventsOn(date: LocalDate): List<EventInstance> = days.firstOrNull { it.date == date }?.events.orEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Widget [id]'s stored options and calendars, with the app's values for what it has none of. */
|
||||||
|
internal class WidgetLoadContext(val settings: WidgetSettings, val options: AgendaWidgetOptions, val hiddenCalendars: Set<Long>)
|
||||||
|
|
||||||
|
internal suspend fun Context.widgetLoadContext(id: GlanceId): WidgetLoadContext {
|
||||||
|
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
|
||||||
|
val state = widgetState(id)
|
||||||
|
return WidgetLoadContext(settings, state.agendaWidgetOptions(settings.agendaDefaults), state.hiddenCalendars)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The events on the days [days] picks from today and the week start, for [load]'s widget. */
|
||||||
|
internal suspend fun Context.loadDays(
|
||||||
|
load: WidgetLoadContext,
|
||||||
|
days: (today: LocalDate, weekStart: DayOfWeek) -> ClosedRange<LocalDate>,
|
||||||
|
): Themed<DaysWidgetData> {
|
||||||
|
val settings = load.settings
|
||||||
|
if (!hasCalendarPermission()) return Themed(DaysWidgetData.NeedsPermission, settings.themeMode)
|
||||||
|
val zone = systemZone()
|
||||||
|
val anchor = today(zone)
|
||||||
|
val weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault())
|
||||||
|
val range = days(anchor, weekStart)
|
||||||
|
val window = range.start.atStartOfDayIn(zone)..range.endInclusive.atTime(23, 59, 59).toInstant(zone)
|
||||||
|
val instances = widgetEntryPoint().calendarRepository().instances(window).first().withoutCalendars(load.hiddenCalendars)
|
||||||
|
return Themed(
|
||||||
|
DaysWidgetData.Ready(
|
||||||
|
today = anchor,
|
||||||
|
firstDay = range.start,
|
||||||
|
lastDay = range.endInclusive,
|
||||||
|
days = groupAgendaDays(range.start, range.endInclusive, instances, zone),
|
||||||
|
weekStart = weekStart,
|
||||||
|
is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)),
|
||||||
|
soften = settings.soften,
|
||||||
|
options = load.options,
|
||||||
|
now = Clock.System.now(),
|
||||||
|
),
|
||||||
|
settings.themeMode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The week [today] falls in, from [weekStart]. */
|
||||||
|
internal fun weekOf(today: LocalDate, weekStart: DayOfWeek): ClosedRange<LocalDate> {
|
||||||
|
val back = (today.dayOfWeek.ordinal - weekStart.ordinal + 7) % 7
|
||||||
|
val first = today.minus(back, DateTimeUnit.DAY)
|
||||||
|
return first..first.plus(6, DateTimeUnit.DAY)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
||||||
|
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
|
||||||
|
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||||
|
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
|
||||||
|
import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidget
|
||||||
|
import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetReceiver
|
||||||
|
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
|
||||||
|
import de.jeanlucmakiola.calendula.widget.today.TodayWidget
|
||||||
|
import de.jeanlucmakiola.calendula.widget.today.TodayWidgetReceiver
|
||||||
|
import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidget
|
||||||
|
import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetReceiver
|
||||||
|
import de.jeanlucmakiola.calendula.widget.split.MonthDayWidget
|
||||||
|
import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetReceiver
|
||||||
|
|
||||||
|
/** A content option a widget's config screen offers, besides background and theme. */
|
||||||
|
enum class WidgetOption { RANGE, TEXT_SIZE, PAST_DISPLAY, SHOW_TODAY, CALENDARS }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every widget the app ships, told apart by their receiver: what redraws,
|
||||||
|
* the rollover and the config screen go through.
|
||||||
|
*/
|
||||||
|
enum class WidgetKind(
|
||||||
|
val receiver: Class<*>,
|
||||||
|
val label: Int,
|
||||||
|
val options: Set<WidgetOption>,
|
||||||
|
/** The size a config preview draws at before the launcher reports one. */
|
||||||
|
val defaultSize: DpSize,
|
||||||
|
) {
|
||||||
|
AGENDA(
|
||||||
|
AgendaWidgetReceiver::class.java,
|
||||||
|
R.string.widget_agenda_label,
|
||||||
|
setOf(WidgetOption.RANGE, WidgetOption.TEXT_SIZE, WidgetOption.PAST_DISPLAY, WidgetOption.SHOW_TODAY, WidgetOption.CALENDARS),
|
||||||
|
DpSize(260.dp, 280.dp),
|
||||||
|
),
|
||||||
|
MONTH(
|
||||||
|
MonthWidgetReceiver::class.java,
|
||||||
|
R.string.widget_month_label,
|
||||||
|
setOf(WidgetOption.CALENDARS),
|
||||||
|
DpSize(320.dp, 340.dp),
|
||||||
|
),
|
||||||
|
QUICK_ADD(
|
||||||
|
QuickAddWidgetReceiver::class.java,
|
||||||
|
R.string.widget_quick_add_label,
|
||||||
|
emptySet(),
|
||||||
|
DpSize(76.dp, 76.dp),
|
||||||
|
),
|
||||||
|
DATE(
|
||||||
|
DateWidgetReceiver::class.java,
|
||||||
|
R.string.widget_date_label,
|
||||||
|
emptySet(),
|
||||||
|
DpSize(76.dp, 76.dp),
|
||||||
|
),
|
||||||
|
WEEK_STRIP(
|
||||||
|
WeekStripWidgetReceiver::class.java,
|
||||||
|
R.string.widget_week_strip_label,
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
TODAY(
|
||||||
|
TodayWidgetReceiver::class.java,
|
||||||
|
R.string.widget_today_label,
|
||||||
|
setOf(WidgetOption.TEXT_SIZE, WidgetOption.PAST_DISPLAY, WidgetOption.CALENDARS),
|
||||||
|
DpSize(300.dp, 100.dp),
|
||||||
|
),
|
||||||
|
COUNTDOWN(
|
||||||
|
CountdownWidgetReceiver::class.java,
|
||||||
|
R.string.widget_countdown_label,
|
||||||
|
setOf(WidgetOption.CALENDARS),
|
||||||
|
DpSize(200.dp, 100.dp),
|
||||||
|
),
|
||||||
|
MONTH_DAY(
|
||||||
|
MonthDayWidgetReceiver::class.java,
|
||||||
|
R.string.widget_month_day_label,
|
||||||
|
setOf(WidgetOption.TEXT_SIZE, WidgetOption.PAST_DISPLAY, WidgetOption.CALENDARS),
|
||||||
|
DpSize(320.dp, 200.dp),
|
||||||
|
),
|
||||||
|
;
|
||||||
|
|
||||||
|
fun widget(): GlanceAppWidget = when (this) {
|
||||||
|
AGENDA -> AgendaWidget()
|
||||||
|
MONTH -> MonthWidget()
|
||||||
|
QUICK_ADD -> QuickAddWidget()
|
||||||
|
DATE -> DateWidget()
|
||||||
|
WEEK_STRIP -> WeekStripWidget()
|
||||||
|
UPCOMING -> UpcomingWidget()
|
||||||
|
TODAY -> TodayWidget()
|
||||||
|
COUNTDOWN -> CountdownWidget()
|
||||||
|
MONTH_DAY -> MonthDayWidget()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this kind stores [AgendaWidgetOptions] of its own. */
|
||||||
|
val hasContentOptions: Boolean
|
||||||
|
get() = options.any { it != WidgetOption.CALENDARS }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun of(context: Context, appWidgetId: Int): WidgetKind? {
|
||||||
|
val provider = AppWidgetManager.getInstance(context)?.getAppWidgetInfo(appWidgetId)?.provider ?: return null
|
||||||
|
return entries.firstOrNull { it.receiver.name == provider.className }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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
|
||||||
|
internal fun widgetCardColor(): ColorProvider = when (LocalWidgetBackground.current) {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
import androidx.glance.appwidget.SizeMode
|
||||||
|
import androidx.glance.appwidget.provideContent
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
|
||||||
|
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetContent
|
||||||
|
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
|
||||||
|
import de.jeanlucmakiola.calendula.widget.countdown.CountdownData
|
||||||
|
import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions
|
||||||
|
import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetContent
|
||||||
|
import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetReceiver
|
||||||
|
import de.jeanlucmakiola.calendula.widget.date.DateWidgetContent
|
||||||
|
import de.jeanlucmakiola.calendula.widget.date.DateWidgetReceiver
|
||||||
|
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetContent
|
||||||
|
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
|
||||||
|
import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetContent
|
||||||
|
import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetReceiver
|
||||||
|
import de.jeanlucmakiola.calendula.widget.split.MonthDayData
|
||||||
|
import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetContent
|
||||||
|
import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetReceiver
|
||||||
|
import de.jeanlucmakiola.calendula.widget.today.TodayWidgetContent
|
||||||
|
import de.jeanlucmakiola.calendula.widget.today.TodayWidgetReceiver
|
||||||
|
import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetContent
|
||||||
|
import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetReceiver
|
||||||
|
import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetContent
|
||||||
|
import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetReceiver
|
||||||
|
import de.jeanlucmakiola.floret.glance.publishWidgetPreviews
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.datetime.atTime
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
|
/** Republishes the generated picker previews (Android 15+) after an app update or a language change. */
|
||||||
|
internal suspend fun publishCalendulaWidgetPreviews(context: Context) {
|
||||||
|
publishWidgetPreviews(
|
||||||
|
context,
|
||||||
|
version = "${context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime}-${Locale.getDefault().toLanguageTag()}",
|
||||||
|
receivers = listOf(
|
||||||
|
AgendaWidgetReceiver::class,
|
||||||
|
MonthWidgetReceiver::class,
|
||||||
|
QuickAddWidgetReceiver::class,
|
||||||
|
DateWidgetReceiver::class,
|
||||||
|
WeekStripWidgetReceiver::class,
|
||||||
|
UpcomingWidgetReceiver::class,
|
||||||
|
TodayWidgetReceiver::class,
|
||||||
|
CountdownWidgetReceiver::class,
|
||||||
|
MonthDayWidgetReceiver::class,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The size [kind]'s picker preview is laid out at. */
|
||||||
|
internal fun previewSizeOf(kind: WidgetKind) = SizeMode.Responsive(setOf(kind.defaultSize))
|
||||||
|
|
||||||
|
/** [kind]'s picker preview: the real widget, drawn with sample events around today's morning. */
|
||||||
|
internal suspend fun GlanceAppWidget.provideSamplePreview(context: Context, kind: WidgetKind) {
|
||||||
|
val settings = context.widgetEntryPoint().settingsPrefs().widgetSettings.first()
|
||||||
|
val zone = systemZone()
|
||||||
|
val today = today(zone)
|
||||||
|
val now = today.atTime(8, 0).toInstant(zone)
|
||||||
|
val instances = context.sampleInstances(today, zone)
|
||||||
|
val weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault())
|
||||||
|
val is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||||
|
val options = settings.agendaDefaults
|
||||||
|
val appearance = WidgetAppearance()
|
||||||
|
val index = monthIndexOf(today)
|
||||||
|
val month = MonthWidgetSource.Ready(today, weekStart, settings.soften, instances, index..index)
|
||||||
|
fun days(range: ClosedRange<LocalDate>) = DaysWidgetData.Ready(
|
||||||
|
today = today,
|
||||||
|
firstDay = range.start,
|
||||||
|
lastDay = range.endInclusive,
|
||||||
|
days = groupAgendaDays(range.start, range.endInclusive, instances, zone),
|
||||||
|
weekStart = weekStart,
|
||||||
|
is24Hour = is24Hour,
|
||||||
|
soften = settings.soften,
|
||||||
|
options = options,
|
||||||
|
now = now,
|
||||||
|
)
|
||||||
|
val theme = settings.themeMode
|
||||||
|
provideContent {
|
||||||
|
when (kind) {
|
||||||
|
WidgetKind.AGENDA -> AgendaWidgetContent(
|
||||||
|
AgendaWidgetData.Ready(
|
||||||
|
today = today,
|
||||||
|
days = groupAgendaDays(today, today.plus(6, DateTimeUnit.DAY), instances, zone),
|
||||||
|
is24Hour = is24Hour,
|
||||||
|
soften = settings.soften,
|
||||||
|
pastDisplay = options.pastDisplay,
|
||||||
|
showToday = options.showToday,
|
||||||
|
textSize = options.textSize,
|
||||||
|
now = now,
|
||||||
|
),
|
||||||
|
appearance,
|
||||||
|
theme,
|
||||||
|
preview = true,
|
||||||
|
)
|
||||||
|
WidgetKind.MONTH -> MonthWidgetContent(month, index, appearance, theme)
|
||||||
|
WidgetKind.MONTH_DAY -> MonthDayWidgetContent(
|
||||||
|
MonthDayData.Ready(month, options, is24Hour, now),
|
||||||
|
index,
|
||||||
|
selected = null,
|
||||||
|
appearance = appearance,
|
||||||
|
appTheme = theme,
|
||||||
|
preview = true,
|
||||||
|
)
|
||||||
|
WidgetKind.QUICK_ADD -> QuickAddWidgetContent(today, appearance, theme)
|
||||||
|
WidgetKind.DATE -> DateWidgetContent(today, appearance, theme)
|
||||||
|
WidgetKind.WEEK_STRIP -> WeekStripWidgetContent(days(weekOf(today, weekStart)), appearance, theme)
|
||||||
|
WidgetKind.UPCOMING -> UpcomingWidgetContent(days(today..today.plus(6, DateTimeUnit.DAY)), appearance, theme)
|
||||||
|
WidgetKind.TODAY -> TodayWidgetContent(days(today..today.plus(1, DateTimeUnit.DAY)), appearance, theme, preview = true)
|
||||||
|
WidgetKind.COUNTDOWN -> CountdownWidgetContent(
|
||||||
|
CountdownData.Ready(
|
||||||
|
options = CountdownOptions(),
|
||||||
|
event = instances.first { !it.isAllDay && it.start > now },
|
||||||
|
now = now,
|
||||||
|
today = today,
|
||||||
|
is24Hour = is24Hour,
|
||||||
|
soften = settings.soften,
|
||||||
|
appWidgetId = null,
|
||||||
|
),
|
||||||
|
appearance,
|
||||||
|
theme,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A plausible few weeks: a busy today, a trip tomorrow, and a scatter of days with something on. */
|
||||||
|
private fun Context.sampleInstances(today: LocalDate, zone: TimeZone): List<EventInstance> {
|
||||||
|
var id = 0L
|
||||||
|
fun timed(day: Int, at: LocalTime, length: kotlin.time.Duration, title: Int, color: Int): EventInstance {
|
||||||
|
val start = today.plus(day, DateTimeUnit.DAY).atTime(at).toInstant(zone)
|
||||||
|
id++
|
||||||
|
return EventInstance(id, id, 1, getString(title), start, start + length, false, color, null)
|
||||||
|
}
|
||||||
|
fun allDay(day: Int, title: Int, color: Int): EventInstance {
|
||||||
|
val date = today.plus(day, DateTimeUnit.DAY)
|
||||||
|
id++
|
||||||
|
return EventInstance(
|
||||||
|
id, id, 1, getString(title),
|
||||||
|
date.atStartOfDayIn(TimeZone.UTC), date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(TimeZone.UTC),
|
||||||
|
true, color, null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return listOf(
|
||||||
|
timed(0, LocalTime(9, 30), 30.minutes, R.string.settings_past_events_sample_morning, Blue),
|
||||||
|
timed(0, LocalTime(12, 30), 1.hours, R.string.settings_past_events_sample_midday, Green),
|
||||||
|
timed(0, LocalTime(19, 0), 2.hours, R.string.settings_past_events_sample_evening, Purple),
|
||||||
|
allDay(1, R.string.widget_preview_sample_trip, Orange),
|
||||||
|
timed(2, LocalTime(10, 0), 1.hours, R.string.widget_preview_sample_dentist, Red),
|
||||||
|
timed(-6, LocalTime(9, 30), 30.minutes, R.string.settings_past_events_sample_morning, Blue),
|
||||||
|
timed(-2, LocalTime(19, 0), 2.hours, R.string.settings_past_events_sample_evening, Purple),
|
||||||
|
timed(5, LocalTime(19, 0), 2.hours, R.string.settings_past_events_sample_evening, Purple),
|
||||||
|
timed(8, LocalTime(9, 30), 30.minutes, R.string.settings_past_events_sample_morning, Blue),
|
||||||
|
timed(12, LocalTime(12, 30), 1.hours, R.string.settings_past_events_sample_midday, Green),
|
||||||
|
).sortedBy { it.start }
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val Blue = 0xFF039BE5.toInt()
|
||||||
|
private const val Green = 0xFF33B679.toInt()
|
||||||
|
private const val Purple = 0xFF8E24AA.toInt()
|
||||||
|
private const val Orange = 0xFFF4511E.toInt()
|
||||||
|
private const val Red = 0xFFD50000.toInt()
|
||||||
@@ -9,10 +9,6 @@ import android.os.SystemClock
|
|||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.di.ApplicationScope
|
import de.jeanlucmakiola.calendula.data.di.ApplicationScope
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
|
|
||||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
|
||||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
|
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetRefresher
|
import de.jeanlucmakiola.floret.glance.WidgetRefresher
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetRollover
|
import de.jeanlucmakiola.floret.glance.WidgetRollover
|
||||||
import de.jeanlucmakiola.floret.glance.updatePlaced
|
import de.jeanlucmakiola.floret.glance.updatePlaced
|
||||||
@@ -33,10 +29,7 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/** Every widget receiver; the set [WidgetRollover] checks for placed instances. */
|
/** Every widget receiver; the set [WidgetRollover] checks for placed instances. */
|
||||||
internal val WIDGET_RECEIVER_CLASSES: List<Class<*>> = listOf(
|
internal val WIDGET_RECEIVER_CLASSES: List<Class<*>> = WidgetKind.entries.map { it.receiver }
|
||||||
MonthWidgetReceiver::class.java,
|
|
||||||
AgendaWidgetReceiver::class.java,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** The app's own midnight wake-up (#228). */
|
/** The app's own midnight wake-up (#228). */
|
||||||
internal const val ACTION_WIDGET_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER"
|
internal const val ACTION_WIDGET_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER"
|
||||||
@@ -57,31 +50,28 @@ internal fun syncWidgetRollover(context: Context) =
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Reloads and redraws every placed widget, a running session included: the
|
* Reloads and redraws every placed widget, a running session included: the
|
||||||
* refresh stamp moves, and both widgets key their data load on it (#420).
|
* refresh stamp moves, and every widget keys its data load on it (#420).
|
||||||
*/
|
*/
|
||||||
internal suspend fun redrawWidgets(context: Context) {
|
internal suspend fun redrawWidgets(context: Context) {
|
||||||
redrawAgendaWidgets(context)
|
WidgetKind.entries.forEach { redrawWidgets(context, it) }
|
||||||
redrawMonthWidgets(context)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun redrawAgendaWidgets(context: Context) =
|
internal suspend fun redrawWidgets(context: Context, kind: WidgetKind) =
|
||||||
AgendaWidget().updatePlaced(context, AgendaWidgetReceiver::class.java, bumpRefreshStamp = true)
|
kind.widget().updatePlaced(context, kind.receiver, bumpRefreshStamp = true)
|
||||||
|
|
||||||
/** Redraws only the agenda widgets with no options of their own, which still follow the app's. */
|
/** Redraws only the widgets with no content options of their own, which still follow the app's. */
|
||||||
private suspend fun redrawUnconfiguredAgendaWidgets(context: Context) {
|
private suspend fun redrawUnconfiguredWidgets(context: Context) {
|
||||||
val appContext = context.applicationContext
|
val appContext = context.applicationContext
|
||||||
val ids = AppWidgetManager.getInstance(appContext)
|
val manager = AppWidgetManager.getInstance(appContext) ?: return
|
||||||
?.getAppWidgetIds(ComponentName(appContext, AgendaWidgetReceiver::class.java)) ?: return
|
|
||||||
val glanceManager = GlanceAppWidgetManager(appContext)
|
val glanceManager = GlanceAppWidgetManager(appContext)
|
||||||
ids.forEach { appWidgetId ->
|
WidgetKind.entries.filter { it.hasContentOptions }.forEach { kind ->
|
||||||
val glanceId = glanceManager.getGlanceIdBy(appWidgetId)
|
manager.getAppWidgetIds(ComponentName(appContext, kind.receiver)).forEach { appWidgetId ->
|
||||||
if (!appContext.widgetState(glanceId).hasAgendaOptions) AgendaWidget().updateWithRefresh(appContext, glanceId)
|
val glanceId = glanceManager.getGlanceIdBy(appWidgetId)
|
||||||
|
if (!appContext.widgetState(glanceId).hasAgendaOptions) kind.widget().updateWithRefresh(appContext, glanceId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun redrawMonthWidgets(context: Context) =
|
|
||||||
MonthWidget().updatePlaced(context, MonthWidgetReceiver::class.java, bumpRefreshStamp = true)
|
|
||||||
|
|
||||||
/** When the app last saw a calendar change of its own, for skipping the `PROVIDER_CHANGED` that echoes it. */
|
/** When the app last saw a calendar change of its own, for skipping the `PROVIDER_CHANGED` that echoes it. */
|
||||||
private val lastLocalChange = AtomicLong(0L)
|
private val lastLocalChange = AtomicLong(0L)
|
||||||
|
|
||||||
@@ -97,7 +87,7 @@ internal fun isProviderEcho(): Boolean {
|
|||||||
* the settings they draw with, so those don't wait for `PROVIDER_CHANGED`,
|
* the settings they draw with, so those don't wait for `PROVIDER_CHANGED`,
|
||||||
* which arrives seconds late or not at all (#420, #229). Changes from outside
|
* which arrives seconds late or not at all (#420, #229). Changes from outside
|
||||||
* the app are [WidgetUpdateReceiver]'s. The app's agenda options only redraw
|
* the app are [WidgetUpdateReceiver]'s. The app's agenda options only redraw
|
||||||
* agenda widgets that have none of their own (#292). Started once from
|
* widgets that have no content options of their own (#292). Started once from
|
||||||
* `CalendulaApp`.
|
* `CalendulaApp`.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
@@ -112,20 +102,17 @@ class WidgetRefreshCoordinator @Inject constructor(
|
|||||||
/** Subscribes before returning, so a change made right after is never taken for the starting value. */
|
/** Subscribes before returning, so a change made right after is never taken for the starting value. */
|
||||||
fun start() {
|
fun start() {
|
||||||
if (!started.compareAndSet(false, true)) return
|
if (!started.compareAndSet(false, true)) return
|
||||||
val agenda = WidgetRefresher(scope) { redrawAgendaWidgets(context) }
|
val all = WidgetKind.entries.map { kind -> WidgetRefresher(scope) { redrawWidgets(context, kind) } }
|
||||||
val unconfiguredAgenda = WidgetRefresher(scope) { redrawUnconfiguredAgendaWidgets(context) }
|
val unconfigured = WidgetRefresher(scope) { redrawUnconfiguredWidgets(context) }
|
||||||
val month = WidgetRefresher(scope) { redrawMonthWidgets(context) }
|
|
||||||
watch(repository.localChanges) {
|
watch(repository.localChanges) {
|
||||||
lastLocalChange.set(SystemClock.elapsedRealtime())
|
lastLocalChange.set(SystemClock.elapsedRealtime())
|
||||||
agenda.request()
|
all.forEach { it.request() }
|
||||||
month.request()
|
|
||||||
}
|
}
|
||||||
watch(prefs.widgetSettings.map { it.shared }.distinctUntilChanged().drop(1)) {
|
watch(prefs.widgetSettings.map { it.shared }.distinctUntilChanged().drop(1)) {
|
||||||
agenda.request()
|
all.forEach { it.request() }
|
||||||
month.request()
|
|
||||||
}
|
}
|
||||||
watch(prefs.widgetSettings.map { it.agendaDefaults }.distinctUntilChanged().drop(1)) {
|
watch(prefs.widgetSettings.map { it.agendaDefaults }.distinctUntilChanged().drop(1)) {
|
||||||
unconfiguredAgenda.request()
|
unconfigured.request()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.agenda
|
package de.jeanlucmakiola.calendula.widget.agenda
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||||
|
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||||
|
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||||
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
|
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -45,13 +48,10 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
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.agenda.anchorTodayIfMissing
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
|
||||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||||
@@ -60,12 +60,12 @@ import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
|||||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||||
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
|
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
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.loadAgendaWidget
|
||||||
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
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.WidgetGroupedRow
|
||||||
import de.jeanlucmakiola.floret.glance.themedColor
|
import de.jeanlucmakiola.floret.glance.themedColor
|
||||||
import de.jeanlucmakiola.floret.glance.wallpaperBacking
|
import de.jeanlucmakiola.floret.glance.wallpaperBacking
|
||||||
@@ -79,11 +79,8 @@ import de.jeanlucmakiola.calendula.widget.systemZone
|
|||||||
import de.jeanlucmakiola.calendula.widget.today
|
import de.jeanlucmakiola.calendula.widget.today
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toJavaDayOfWeek
|
import kotlinx.datetime.toJavaDayOfWeek
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlin.time.Instant
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -101,6 +98,11 @@ class AgendaWidget : GlanceAppWidget() {
|
|||||||
// the RemoteViews past the binder transaction limit.
|
// the RemoteViews past the binder transaction limit.
|
||||||
override val sizeMode = SizeMode.Single
|
override val sizeMode = SizeMode.Single
|
||||||
|
|
||||||
|
override val previewSizeMode = previewSizeOf(WidgetKind.AGENDA)
|
||||||
|
|
||||||
|
override suspend fun providePreview(context: Context, widgetCategory: Int) =
|
||||||
|
provideSamplePreview(context, WidgetKind.AGENDA)
|
||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val stamp = refreshStamp(context, id)
|
val stamp = refreshStamp(context, id)
|
||||||
val widgetData = WidgetData(context.loadAgendaWidget(id), stamp)
|
val widgetData = WidgetData(context.loadAgendaWidget(id), stamp)
|
||||||
@@ -217,7 +219,7 @@ private fun AgendaRows(
|
|||||||
// Each day is one card of grouped rows (#71), with its date pill
|
// Each day is one card of grouped rows (#71), with its date pill
|
||||||
// beside the first row.
|
// beside the first row.
|
||||||
val positions = dayPositions(rows)
|
val positions = dayPositions(rows)
|
||||||
val cardColor = agendaCardColor()
|
val cardColor = widgetCardColor()
|
||||||
val item: @Composable (Int) -> Unit = { index ->
|
val item: @Composable (Int) -> Unit = { index ->
|
||||||
val row = rows[index]
|
val row = rows[index]
|
||||||
val position = positions[index]
|
val position = positions[index]
|
||||||
@@ -305,13 +307,6 @@ private fun AgendaHeader() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cards sit one tone off the root fill, and stay opaque over a wallpaper (#292). */
|
|
||||||
@Composable
|
|
||||||
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. */
|
/** The month name ("November", with its year outside the current one) above a new month's first day. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun MonthLabel(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) {
|
private fun MonthLabel(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) {
|
||||||
@@ -470,8 +465,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. */
|
/** "Today · Wed, 17 Jun" — relative word for today/tomorrow, else the date. */
|
||||||
private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate): String {
|
private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate): String {
|
||||||
val relative = when (date) {
|
val relative = when (date) {
|
||||||
@@ -503,20 +496,7 @@ private fun eventTimeSummary(
|
|||||||
day: LocalDate,
|
day: LocalDate,
|
||||||
is24Hour: Boolean,
|
is24Hour: Boolean,
|
||||||
): String {
|
): String {
|
||||||
val time = when (val label = agendaTimeLabel(event, day, zone())) {
|
val time = widgetTimeLabel(context, event, day, is24Hour)
|
||||||
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 location = event.location?.takeIf { it.isNotBlank() }
|
val location = event.location?.takeIf { it.isNotBlank() }
|
||||||
return if (location != null) "$time · $location" else time
|
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())
|
|
||||||
}
|
|
||||||
|
|||||||
+3
-48
@@ -1,54 +1,9 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.agenda
|
package de.jeanlucmakiola.calendula.widget.agenda
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.appwidget.AppWidgetManager
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.glance.appwidget.GlanceAppWidget
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
|
||||||
import de.jeanlucmakiola.calendula.widget.syncWidgetRollover
|
|
||||||
import de.jeanlucmakiola.floret.glance.reloadOnUpdate
|
|
||||||
import de.jeanlucmakiola.floret.glance.restoreState
|
|
||||||
|
|
||||||
/**
|
/** Host-facing receiver for the agenda widget; rendering is [AgendaWidget]'s. */
|
||||||
* Host-facing receiver for the agenda widget. Declared in the manifest with the
|
class AgendaWidgetReceiver : CalendulaWidgetReceiver() {
|
||||||
* `appwidget_info_agenda` provider metadata; delegates all rendering to
|
|
||||||
* [AgendaWidget].
|
|
||||||
*/
|
|
||||||
class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
|
|
||||||
override val glanceAppWidget: GlanceAppWidget = AgendaWidget()
|
override val glanceAppWidget: GlanceAppWidget = AgendaWidget()
|
||||||
|
|
||||||
/** First agenda widget placed — start rolling "today" over at midnight (#228). */
|
|
||||||
override fun onEnabled(context: Context) {
|
|
||||||
super.onEnabled(context)
|
|
||||||
syncWidgetRollover(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Last agenda widget removed. [syncWidgetRollover] cancels only if
|
|
||||||
* no month widget is left either.
|
|
||||||
*/
|
|
||||||
override fun onDisabled(context: Context) {
|
|
||||||
super.onDisabled(context)
|
|
||||||
syncWidgetRollover(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A backup restore or device transfer renumbered the widgets: keep each one's settings (#292). */
|
|
||||||
override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) {
|
|
||||||
restoreState(context, oldWidgetIds, newWidgetIds)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Self-heal on the system's own `updatePeriodMillis` wake-up — see
|
|
||||||
* [de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver.onUpdate].
|
|
||||||
*/
|
|
||||||
@SuppressLint("MissingSuperCall")
|
|
||||||
override fun onUpdate(
|
|
||||||
context: Context,
|
|
||||||
appWidgetManager: AppWidgetManager,
|
|
||||||
appWidgetIds: IntArray,
|
|
||||||
) {
|
|
||||||
// In place of super, which only recomposes and would take the goAsync handle.
|
|
||||||
reloadOnUpdate(context, appWidgetIds)
|
|
||||||
syncWidgetRollover(context)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+178
@@ -0,0 +1,178 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.config
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.PickerSearchField
|
||||||
|
import de.jeanlucmakiola.calendula.widget.countdown.CountdownMode
|
||||||
|
import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions
|
||||||
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedChoiceRow
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
|
||||||
|
/** What the countdown widget counts to (#439): the next event, or one picked from a searchable list. */
|
||||||
|
@Composable
|
||||||
|
internal fun CountdownOptionsSection(
|
||||||
|
options: CountdownOptions,
|
||||||
|
candidates: CountdownCandidates,
|
||||||
|
onChange: (CountdownOptions) -> Unit,
|
||||||
|
onLoadCandidates: () -> Unit,
|
||||||
|
) {
|
||||||
|
var picking by rememberSaveable { mutableStateOf(false) }
|
||||||
|
GroupedSectionHeader(stringResource(R.string.widget_config_countdown))
|
||||||
|
GroupedChoiceRow(
|
||||||
|
title = stringResource(R.string.widget_config_countdown),
|
||||||
|
options = CountdownMode.entries,
|
||||||
|
selected = options.mode,
|
||||||
|
label = {
|
||||||
|
stringResource(
|
||||||
|
when (it) {
|
||||||
|
CountdownMode.NEXT -> R.string.widget_config_countdown_next
|
||||||
|
CountdownMode.PINNED -> R.string.widget_config_countdown_pinned
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSelect = { mode ->
|
||||||
|
onChange(options.copy(mode = mode))
|
||||||
|
if (mode == CountdownMode.PINNED && options.eventId == null) picking = true
|
||||||
|
},
|
||||||
|
position = if (options.mode == CountdownMode.PINNED) Position.Top else Position.Alone,
|
||||||
|
)
|
||||||
|
when (options.mode) {
|
||||||
|
CountdownMode.NEXT -> GroupedHint(stringResource(R.string.widget_config_countdown_next_hint), verticalPadding = 8.dp)
|
||||||
|
CountdownMode.PINNED -> GroupedRow(
|
||||||
|
title = stringResource(R.string.widget_config_countdown_event),
|
||||||
|
summary = options.title ?: stringResource(R.string.widget_config_countdown_event_none),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = { picking = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (picking) {
|
||||||
|
LaunchedEffect(Unit) { onLoadCandidates() }
|
||||||
|
CountdownEventPicker(
|
||||||
|
candidates = candidates,
|
||||||
|
selectedEventId = options.eventId,
|
||||||
|
onRetry = onLoadCandidates,
|
||||||
|
onSelect = { candidate ->
|
||||||
|
onChange(options.copy(mode = CountdownMode.PINNED, eventId = candidate.event.eventId, title = candidate.event.title))
|
||||||
|
picking = false
|
||||||
|
},
|
||||||
|
onDismiss = { picking = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CountdownEventPicker(
|
||||||
|
candidates: CountdownCandidates,
|
||||||
|
selectedEventId: Long?,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
onSelect: (CountdownCandidate) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var query by rememberSaveable { mutableStateOf("") }
|
||||||
|
FullScreenPicker(
|
||||||
|
title = stringResource(R.string.widget_config_countdown_event),
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
predictiveBack = true,
|
||||||
|
scrollable = false,
|
||||||
|
) {
|
||||||
|
PickerSearchField(
|
||||||
|
query = query,
|
||||||
|
onQueryChange = { query = it },
|
||||||
|
placeholder = stringResource(R.string.widget_config_countdown_search),
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
when (candidates) {
|
||||||
|
CountdownCandidates.Idle, CountdownCandidates.Loading -> Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) { CircularProgressIndicator() }
|
||||||
|
CountdownCandidates.Failed -> Column(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
PickerMessage(stringResource(R.string.widget_config_countdown_load_failed))
|
||||||
|
FilledTonalButton(onClick = onRetry, modifier = Modifier.padding(top = 16.dp)) {
|
||||||
|
Text(stringResource(R.string.state_retry))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is CountdownCandidates.Ready -> {
|
||||||
|
val locale = currentLocale()
|
||||||
|
val matches = remember(candidates, query) {
|
||||||
|
val needle = query.trim()
|
||||||
|
if (needle.isEmpty()) candidates.events
|
||||||
|
else candidates.events.filter { it.event.title.contains(needle, ignoreCase = true) }
|
||||||
|
}
|
||||||
|
if (matches.isEmpty()) {
|
||||||
|
PickerMessage(
|
||||||
|
stringResource(R.string.widget_config_countdown_empty),
|
||||||
|
Modifier.fillMaxWidth().padding(24.dp),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
|
val formatter = remember(locale) { localizedDateFormatter(locale, "EEEdMMMy") }
|
||||||
|
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) {
|
||||||
|
itemsIndexed(matches, key = { _, it -> it.event.eventId }) { index, candidate ->
|
||||||
|
val date = formatter.format(candidate.event.start.toLocalDateTime(zone).date.toJavaLocalDate())
|
||||||
|
GroupedRow(
|
||||||
|
title = candidate.event.title.ifBlank { stringResource(R.string.event_untitled) },
|
||||||
|
summary = if (candidate.repeats) stringResource(R.string.widget_config_countdown_repeats, date) else date,
|
||||||
|
position = positionOf(index, matches.size),
|
||||||
|
selected = candidate.event.eventId == selectedEventId,
|
||||||
|
minHeight = 56.dp,
|
||||||
|
leading = { CalendarColorChip(candidate.event.color) },
|
||||||
|
onClick = { onSelect(candidate) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PickerMessage(text: String, modifier: Modifier = Modifier) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import android.content.ComponentName
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||||
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||||
import de.jeanlucmakiola.calendula.widget.widgetState
|
import de.jeanlucmakiola.calendula.widget.widgetState
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.config
|
||||||
|
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
|
||||||
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
|
||||||
|
/** Which of the app's calendars one widget shows, grouped by account like the drawer's filter. */
|
||||||
|
@Composable
|
||||||
|
internal fun WidgetCalendarPicker(
|
||||||
|
calendars: List<CalendarSource>,
|
||||||
|
hidden: Set<Long>,
|
||||||
|
onSetShown: (Long, Boolean) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
FullScreenPicker(
|
||||||
|
title = stringResource(R.string.widget_config_calendars),
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
predictiveBack = true,
|
||||||
|
) {
|
||||||
|
GroupedHint(stringResource(R.string.widget_config_calendars_hint), verticalPadding = 8.dp)
|
||||||
|
calendars.groupByAccount().forEach { group ->
|
||||||
|
GroupedSectionHeader(accountGroupTitle(group))
|
||||||
|
group.calendars.forEachIndexed { index, calendar ->
|
||||||
|
val shown = calendar.id !in hidden
|
||||||
|
GroupedRow(
|
||||||
|
title = calendar.displayName.orUnnamed(),
|
||||||
|
position = positionOf(index, group.calendars.size),
|
||||||
|
minHeight = 56.dp,
|
||||||
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
trailing = { Checkbox(checked = shown, onCheckedChange = { onSetShown(calendar.id, it) }) },
|
||||||
|
onClick = { onSetShown(calendar.id, !shown) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "All calendars", or how many of them the widget shows. */
|
||||||
|
@Composable
|
||||||
|
internal fun calendarsSummary(calendars: List<CalendarSource>, hidden: Set<Long>): String {
|
||||||
|
val shown = calendars.count { it.id !in hidden }
|
||||||
|
return if (shown == calendars.size) {
|
||||||
|
stringResource(R.string.widget_config_calendars_all)
|
||||||
|
} else {
|
||||||
|
pluralStringResource(R.plurals.widget_config_calendars_some, calendars.size, shown, calendars.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -113,7 +113,10 @@ class WidgetConfigActivity : ComponentActivity() {
|
|||||||
onSave = viewModel::save,
|
onSave = viewModel::save,
|
||||||
onRetry = viewModel::retry,
|
onRetry = viewModel::retry,
|
||||||
onAppearance = viewModel::setAppearance,
|
onAppearance = viewModel::setAppearance,
|
||||||
onAgendaOptions = viewModel::setAgendaOptions,
|
onContent = viewModel::setContent,
|
||||||
|
onCalendarShown = viewModel::setCalendarShown,
|
||||||
|
onCountdown = viewModel::setCountdown,
|
||||||
|
onLoadCountdownCandidates = viewModel::loadCountdownCandidates,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+105
-37
@@ -42,11 +42,21 @@ import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
|||||||
import de.jeanlucmakiola.calendula.ui.settings.pastEventDisplayLabelRes
|
import de.jeanlucmakiola.calendula.ui.settings.pastEventDisplayLabelRes
|
||||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
|
import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetOption
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetTheme
|
import de.jeanlucmakiola.calendula.widget.WidgetTheme
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetContent
|
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetContent
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
|
import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
|
||||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetContent
|
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.today.TodayWidgetContent
|
||||||
|
import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions
|
||||||
|
import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetContent
|
||||||
|
import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetContent
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||||
import de.jeanlucmakiola.floret.components.GroupedChoiceRow
|
import de.jeanlucmakiola.floret.components.GroupedChoiceRow
|
||||||
import de.jeanlucmakiola.floret.components.GroupedHint
|
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
@@ -54,6 +64,7 @@ import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
|||||||
import de.jeanlucmakiola.floret.components.GroupedSwitchRow
|
import de.jeanlucmakiola.floret.components.GroupedSwitchRow
|
||||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.floret.glance.GlanceWidgetPreview
|
import de.jeanlucmakiola.floret.glance.GlanceWidgetPreview
|
||||||
import de.jeanlucmakiola.floret.glance.WallpaperWindow
|
import de.jeanlucmakiola.floret.glance.WallpaperWindow
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||||
@@ -69,7 +80,10 @@ internal fun WidgetConfigScreen(
|
|||||||
onSave: () -> Unit,
|
onSave: () -> Unit,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onAppearance: (WidgetAppearance) -> Unit,
|
onAppearance: (WidgetAppearance) -> Unit,
|
||||||
onAgendaOptions: (AgendaWidgetOptions) -> Unit,
|
onContent: (AgendaWidgetOptions) -> Unit,
|
||||||
|
onCalendarShown: (Long, Boolean) -> Unit,
|
||||||
|
onCountdown: (CountdownOptions) -> Unit,
|
||||||
|
onLoadCountdownCandidates: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val ready = state as? WidgetConfigUiState.Ready
|
val ready = state as? WidgetConfigUiState.Ready
|
||||||
val surface = MaterialTheme.colorScheme.surface
|
val surface = MaterialTheme.colorScheme.surface
|
||||||
@@ -143,6 +157,27 @@ internal fun WidgetConfigScreen(
|
|||||||
AgendaWidgetContent(preview.data, state.appearance, state.appTheme, preview = true)
|
AgendaWidgetContent(preview.data, state.appearance, state.appTheme, preview = true)
|
||||||
is WidgetPreviewData.Month ->
|
is WidgetPreviewData.Month ->
|
||||||
MonthWidgetContent(preview.source, preview.index, state.appearance, state.appTheme)
|
MonthWidgetContent(preview.source, preview.index, state.appearance, state.appTheme)
|
||||||
|
is WidgetPreviewData.MonthDay -> MonthDayWidgetContent(
|
||||||
|
preview.data,
|
||||||
|
preview.index,
|
||||||
|
selected = null,
|
||||||
|
appearance = state.appearance,
|
||||||
|
appTheme = state.appTheme,
|
||||||
|
preview = true,
|
||||||
|
)
|
||||||
|
is WidgetPreviewData.Countdown ->
|
||||||
|
CountdownWidgetContent(preview.data, state.appearance, state.appTheme)
|
||||||
|
is WidgetPreviewData.Days -> when (state.kind) {
|
||||||
|
WidgetKind.TODAY ->
|
||||||
|
TodayWidgetContent(preview.data, state.appearance, state.appTheme, preview = true)
|
||||||
|
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) {
|
||||||
|
DateWidgetContent(preview.today, state.appearance, state.appTheme)
|
||||||
|
} else {
|
||||||
|
QuickAddWidgetContent(preview.today, state.appearance, state.appTheme)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,7 +185,10 @@ internal fun WidgetConfigScreen(
|
|||||||
ConfigOptions(
|
ConfigOptions(
|
||||||
state = state,
|
state = state,
|
||||||
onAppearance = onAppearance,
|
onAppearance = onAppearance,
|
||||||
onAgendaOptions = onAgendaOptions,
|
onContent = onContent,
|
||||||
|
onCalendarShown = onCalendarShown,
|
||||||
|
onCountdown = onCountdown,
|
||||||
|
onLoadCountdownCandidates = onLoadCountdownCandidates,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.background(surface),
|
.background(surface),
|
||||||
@@ -173,17 +211,14 @@ private fun PreviewFailed() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal val WidgetKind.label: Int
|
|
||||||
get() = when (this) {
|
|
||||||
WidgetKind.AGENDA -> R.string.widget_agenda_label
|
|
||||||
WidgetKind.MONTH -> R.string.widget_month_label
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ConfigOptions(
|
private fun ConfigOptions(
|
||||||
state: WidgetConfigUiState.Ready,
|
state: WidgetConfigUiState.Ready,
|
||||||
onAppearance: (WidgetAppearance) -> Unit,
|
onAppearance: (WidgetAppearance) -> Unit,
|
||||||
onAgendaOptions: (AgendaWidgetOptions) -> Unit,
|
onContent: (AgendaWidgetOptions) -> Unit,
|
||||||
|
onCalendarShown: (Long, Boolean) -> Unit,
|
||||||
|
onCountdown: (CountdownOptions) -> Unit,
|
||||||
|
onLoadCountdownCandidates: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -209,47 +244,80 @@ private fun ConfigOptions(
|
|||||||
onSelect = { onAppearance(state.appearance.copy(theme = it)) },
|
onSelect = { onAppearance(state.appearance.copy(theme = it)) },
|
||||||
position = Position.Bottom,
|
position = Position.Bottom,
|
||||||
)
|
)
|
||||||
state.agenda?.let { options ->
|
if (state.kind == WidgetKind.COUNTDOWN) {
|
||||||
AgendaOptions(options = options, state = state, onChange = onAgendaOptions)
|
CountdownOptionsSection(
|
||||||
|
options = state.draft.countdown,
|
||||||
|
candidates = state.candidates,
|
||||||
|
onChange = onCountdown,
|
||||||
|
onLoadCandidates = onLoadCountdownCandidates,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (state.kind.options.isNotEmpty()) {
|
||||||
|
ContentOptions(state = state, onChange = onContent, onCalendarShown = onCalendarShown)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaOptions(
|
private fun ContentOptions(
|
||||||
options: AgendaWidgetOptions,
|
|
||||||
state: WidgetConfigUiState.Ready,
|
state: WidgetConfigUiState.Ready,
|
||||||
onChange: (AgendaWidgetOptions) -> Unit,
|
onChange: (AgendaWidgetOptions) -> Unit,
|
||||||
|
onCalendarShown: (Long, Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val options = state.draft.content
|
||||||
var showRange by rememberSaveable { mutableStateOf(false) }
|
var showRange by rememberSaveable { mutableStateOf(false) }
|
||||||
var showSize by rememberSaveable { mutableStateOf(false) }
|
var showSize by rememberSaveable { mutableStateOf(false) }
|
||||||
var showPast by rememberSaveable { mutableStateOf(false) }
|
var showPast by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var showCalendars by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Rows in WidgetOption order, each shaped by where it lands among the kind's own.
|
||||||
|
val rows = WidgetOption.entries.filter { it in state.kind.options }
|
||||||
|
fun position(option: WidgetOption) = positionOf(rows.indexOf(option), rows.size)
|
||||||
|
|
||||||
GroupedSectionHeader(stringResource(R.string.widget_config_content))
|
GroupedSectionHeader(stringResource(R.string.widget_config_content))
|
||||||
GroupedRow(
|
rows.forEach { option ->
|
||||||
title = stringResource(R.string.widget_config_range),
|
when (option) {
|
||||||
summary = agendaRangeLabel(options.range),
|
WidgetOption.RANGE -> GroupedRow(
|
||||||
position = Position.Top,
|
title = stringResource(R.string.widget_config_range),
|
||||||
onClick = { showRange = true },
|
summary = agendaRangeLabel(options.range),
|
||||||
)
|
position = position(option),
|
||||||
GroupedRow(
|
onClick = { showRange = true },
|
||||||
title = stringResource(R.string.widget_config_text_size),
|
)
|
||||||
summary = widgetSizeLabel(options.textSize),
|
WidgetOption.TEXT_SIZE -> GroupedRow(
|
||||||
position = Position.Middle,
|
title = stringResource(R.string.widget_config_text_size),
|
||||||
onClick = { showSize = true },
|
summary = widgetSizeLabel(options.textSize),
|
||||||
)
|
position = position(option),
|
||||||
GroupedRow(
|
onClick = { showSize = true },
|
||||||
title = stringResource(R.string.settings_past_events),
|
)
|
||||||
summary = stringResource(pastEventDisplayLabelRes(options.pastDisplay)),
|
WidgetOption.PAST_DISPLAY -> GroupedRow(
|
||||||
position = Position.Middle,
|
title = stringResource(R.string.settings_past_events),
|
||||||
onClick = { showPast = true },
|
summary = stringResource(pastEventDisplayLabelRes(options.pastDisplay)),
|
||||||
)
|
position = position(option),
|
||||||
GroupedSwitchRow(
|
onClick = { showPast = true },
|
||||||
title = stringResource(R.string.settings_agenda_show_today),
|
)
|
||||||
checked = options.showToday,
|
WidgetOption.SHOW_TODAY -> GroupedSwitchRow(
|
||||||
onCheckedChange = { onChange(options.copy(showToday = it)) },
|
title = stringResource(R.string.settings_agenda_show_today),
|
||||||
position = Position.Bottom,
|
checked = options.showToday,
|
||||||
)
|
onCheckedChange = { onChange(options.copy(showToday = it)) },
|
||||||
|
position = position(option),
|
||||||
|
)
|
||||||
|
WidgetOption.CALENDARS -> GroupedRow(
|
||||||
|
title = stringResource(R.string.widget_config_calendars),
|
||||||
|
summary = calendarsSummary(state.calendars, state.draft.hiddenCalendars),
|
||||||
|
position = position(option),
|
||||||
|
onClick = { showCalendars = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showCalendars) {
|
||||||
|
WidgetCalendarPicker(
|
||||||
|
calendars = state.calendars,
|
||||||
|
hidden = state.draft.hiddenCalendars,
|
||||||
|
onSetShown = onCalendarShown,
|
||||||
|
onDismiss = { showCalendars = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (showRange) {
|
if (showRange) {
|
||||||
AgendaRangePicker(
|
AgendaRangePicker(
|
||||||
|
|||||||
+140
-47
@@ -4,25 +4,44 @@ import android.appwidget.AppWidgetManager
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.compose.ui.unit.DpSize
|
import androidx.compose.ui.unit.DpSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.glance.appwidget.GlanceAppWidget
|
|
||||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||||
|
import de.jeanlucmakiola.calendula.widget.DaysWidgetData
|
||||||
|
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.today.loadTodayList
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.widget.countdown.CountdownData
|
||||||
|
import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions
|
||||||
|
import de.jeanlucmakiola.calendula.widget.countdown.countdownOptions
|
||||||
|
import de.jeanlucmakiola.calendula.widget.countdown.loadCountdown
|
||||||
|
import de.jeanlucmakiola.calendula.widget.countdown.putCountdownOptions
|
||||||
|
import de.jeanlucmakiola.calendula.widget.split.MonthDayData
|
||||||
|
import de.jeanlucmakiola.calendula.widget.split.loadMonthDay
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration.Companion.days
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetOption
|
||||||
|
import de.jeanlucmakiola.calendula.widget.hiddenCalendars
|
||||||
|
import de.jeanlucmakiola.calendula.widget.putHiddenCalendars
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
|
import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
|
||||||
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
|
|
||||||
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
|
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
|
||||||
import de.jeanlucmakiola.calendula.widget.loadThemedMonthSource
|
import de.jeanlucmakiola.calendula.widget.loadThemedMonthSource
|
||||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
|
||||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
|
|
||||||
import de.jeanlucmakiola.calendula.widget.monthIndexOf
|
import de.jeanlucmakiola.calendula.widget.monthIndexOf
|
||||||
import de.jeanlucmakiola.calendula.widget.monthWindowCenter
|
import de.jeanlucmakiola.calendula.widget.monthWindowCenter
|
||||||
import de.jeanlucmakiola.calendula.widget.agendaWidgetOptions
|
import de.jeanlucmakiola.calendula.widget.agendaWidgetOptions
|
||||||
@@ -48,33 +67,41 @@ import kotlinx.coroutines.flow.first
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/** The widgets a config screen can edit, told apart by their receiver. */
|
|
||||||
enum class WidgetKind(val receiver: Class<*>) {
|
|
||||||
AGENDA(AgendaWidgetReceiver::class.java),
|
|
||||||
MONTH(MonthWidgetReceiver::class.java),
|
|
||||||
;
|
|
||||||
|
|
||||||
fun widget(): GlanceAppWidget = when (this) {
|
|
||||||
AGENDA -> AgendaWidget()
|
|
||||||
MONTH -> MonthWidget()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun of(context: Context, appWidgetId: Int): WidgetKind? {
|
|
||||||
val provider = AppWidgetManager.getInstance(context)?.getAppWidgetInfo(appWidgetId)?.provider ?: return null
|
|
||||||
return entries.firstOrNull { it.receiver.name == provider.className }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What the preview draws, loaded with the draft's options. */
|
/** What the preview draws, loaded with the draft's options. */
|
||||||
sealed interface WidgetPreviewData {
|
sealed interface WidgetPreviewData {
|
||||||
data class Agenda(val data: AgendaWidgetData) : WidgetPreviewData
|
data class Agenda(val data: AgendaWidgetData) : WidgetPreviewData
|
||||||
data class Month(val source: MonthWidgetSource, val index: Int) : WidgetPreviewData
|
data class Month(val source: MonthWidgetSource, val index: Int) : WidgetPreviewData
|
||||||
|
data class Today(val today: LocalDate) : WidgetPreviewData
|
||||||
|
data class Days(val data: DaysWidgetData) : WidgetPreviewData
|
||||||
|
data class Countdown(val data: CountdownData) : WidgetPreviewData
|
||||||
|
data class MonthDay(val data: MonthDayData, val index: Int) : WidgetPreviewData
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The options being edited, applied to the preview as they change and written on save. */
|
||||||
|
data class WidgetDraft(
|
||||||
|
val appearance: WidgetAppearance,
|
||||||
|
/** The content options; stored only for a kind that [WidgetKind.hasContentOptions]. */
|
||||||
|
val content: AgendaWidgetOptions,
|
||||||
|
val hiddenCalendars: Set<Long>,
|
||||||
|
val countdown: CountdownOptions = CountdownOptions(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One event or series the countdown can be pinned to, at its next occurrence. */
|
||||||
|
data class CountdownCandidate(val event: EventInstance, val repeats: Boolean)
|
||||||
|
|
||||||
|
/** The countdown's event list, loaded when its picker first opens. */
|
||||||
|
sealed interface CountdownCandidates {
|
||||||
|
data object Idle : CountdownCandidates
|
||||||
|
data object Loading : CountdownCandidates
|
||||||
|
data object Failed : CountdownCandidates
|
||||||
|
data class Ready(val events: List<CountdownCandidate>) : CountdownCandidates
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun WidgetDraft.loadContext(settings: WidgetSettings) = WidgetLoadContext(settings, content, hiddenCalendars)
|
||||||
|
|
||||||
sealed interface WidgetConfigUiState {
|
sealed interface WidgetConfigUiState {
|
||||||
data object Loading : WidgetConfigUiState
|
data object Loading : WidgetConfigUiState
|
||||||
|
|
||||||
@@ -86,19 +113,22 @@ sealed interface WidgetConfigUiState {
|
|||||||
|
|
||||||
data class Ready(
|
data class Ready(
|
||||||
val kind: WidgetKind,
|
val kind: WidgetKind,
|
||||||
val appearance: WidgetAppearance,
|
val draft: WidgetDraft,
|
||||||
/** Null for a widget with no content options. */
|
/** The calendars the app shows, for the widget's own calendar choice. */
|
||||||
val agenda: AgendaWidgetOptions?,
|
val calendars: List<CalendarSource>,
|
||||||
val appTheme: ThemeMode,
|
val appTheme: ThemeMode,
|
||||||
val weekStart: DayOfWeek,
|
val weekStart: DayOfWeek,
|
||||||
/** The widget's size on the home screen, which the preview is laid out at. */
|
/** The widget's size on the home screen, which the preview is laid out at. */
|
||||||
val placedSize: DpSize,
|
val placedSize: DpSize,
|
||||||
/** Null until the first preview load lands. */
|
/** Null until the first preview load lands. */
|
||||||
val preview: WidgetPreviewData?,
|
val preview: WidgetPreviewData?,
|
||||||
|
val candidates: CountdownCandidates = CountdownCandidates.Idle,
|
||||||
/** The first preview load failed, so there's nothing to show. */
|
/** The first preview load failed, so there's nothing to show. */
|
||||||
val previewFailed: Boolean = false,
|
val previewFailed: Boolean = false,
|
||||||
val saving: Boolean = false,
|
val saving: Boolean = false,
|
||||||
) : WidgetConfigUiState
|
) : WidgetConfigUiState {
|
||||||
|
val appearance: WidgetAppearance get() = draft.appearance
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One-time outcomes of a save, for the screen that's showing when they land. */
|
/** One-time outcomes of a save, for the screen that's showing when they land. */
|
||||||
@@ -113,6 +143,8 @@ enum class WidgetConfigEvent { SAVED, SAVE_FAILED }
|
|||||||
class WidgetConfigViewModel @Inject constructor(
|
class WidgetConfigViewModel @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val prefs: SettingsPrefs,
|
private val prefs: SettingsPrefs,
|
||||||
|
private val calendarPrefs: CalendarPrefs,
|
||||||
|
private val repository: CalendarRepository,
|
||||||
savedState: SavedStateHandle,
|
savedState: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -147,8 +179,13 @@ class WidgetConfigViewModel @Inject constructor(
|
|||||||
val settings = prefs.widgetSettings.first()
|
val settings = prefs.widgetSettings.first()
|
||||||
WidgetConfigUiState.Ready(
|
WidgetConfigUiState.Ready(
|
||||||
kind = kind,
|
kind = kind,
|
||||||
appearance = stored.widgetAppearance,
|
draft = WidgetDraft(
|
||||||
agenda = if (kind == WidgetKind.AGENDA) stored.agendaWidgetOptions(settings.agendaDefaults) else null,
|
appearance = stored.widgetAppearance,
|
||||||
|
content = stored.agendaWidgetOptions(settings.agendaDefaults),
|
||||||
|
hiddenCalendars = stored.hiddenCalendars,
|
||||||
|
countdown = stored.countdownOptions,
|
||||||
|
),
|
||||||
|
calendars = if (WidgetOption.CALENDARS in kind.options) shownCalendars() else emptyList(),
|
||||||
appTheme = settings.themeMode,
|
appTheme = settings.themeMode,
|
||||||
weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()),
|
weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()),
|
||||||
placedSize = placedSize(kind),
|
placedSize = placedSize(kind),
|
||||||
@@ -171,16 +208,56 @@ class WidgetConfigViewModel @Inject constructor(
|
|||||||
val width = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) ?: 0
|
val width = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) ?: 0
|
||||||
val height = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT) ?: 0
|
val height = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT) ?: 0
|
||||||
if (width > 0 && height > 0) return DpSize(width.dp, height.dp)
|
if (width > 0 && height > 0) return DpSize(width.dp, height.dp)
|
||||||
return when (kind) {
|
return kind.defaultSize
|
||||||
WidgetKind.AGENDA -> DpSize(260.dp, 280.dp)
|
}
|
||||||
WidgetKind.MONTH -> DpSize(320.dp, 340.dp)
|
|
||||||
|
/** The calendars the app itself shows; the ones hidden there never reach a widget anyway. */
|
||||||
|
private suspend fun shownCalendars(): List<CalendarSource> {
|
||||||
|
val hidden = calendarPrefs.hiddenCalendarIds.first()
|
||||||
|
return repository.calendars().first().filter { it.isVisibleInSystem && it.id !in hidden }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAppearance(appearance: WidgetAppearance) = edit { it.copy(draft = it.draft.copy(appearance = appearance)) }
|
||||||
|
|
||||||
|
fun setContent(options: AgendaWidgetOptions) {
|
||||||
|
edit { it.copy(draft = it.draft.copy(content = options)) }
|
||||||
|
reloadPreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCountdown(options: CountdownOptions) {
|
||||||
|
edit { it.copy(draft = it.draft.copy(countdown = options)) }
|
||||||
|
reloadPreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loads the events the countdown can be pinned to, unless they're loaded or loading. */
|
||||||
|
fun loadCountdownCandidates() {
|
||||||
|
val ready = _state.value as? WidgetConfigUiState.Ready ?: return
|
||||||
|
if (ready.candidates is CountdownCandidates.Loading || ready.candidates is CountdownCandidates.Ready) return
|
||||||
|
edit { it.copy(candidates = CountdownCandidates.Loading) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val candidates = try {
|
||||||
|
val now = Clock.System.now()
|
||||||
|
val occurrences = repository.instances(now..now + 365.days).first().filter { it.end > now }
|
||||||
|
val perEvent = occurrences.groupBy { it.eventId }
|
||||||
|
CountdownCandidates.Ready(
|
||||||
|
perEvent.values
|
||||||
|
.map { CountdownCandidate(it.minBy { o -> o.start }, repeats = it.size > 1) }
|
||||||
|
.sortedBy { it.event.start },
|
||||||
|
)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (_: Exception) {
|
||||||
|
CountdownCandidates.Failed
|
||||||
|
}
|
||||||
|
edit { it.copy(candidates = candidates) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setAppearance(appearance: WidgetAppearance) = edit { it.copy(appearance = appearance) }
|
fun setCalendarShown(id: Long, shown: Boolean) {
|
||||||
|
edit {
|
||||||
fun setAgendaOptions(options: AgendaWidgetOptions) {
|
val hidden = it.draft.hiddenCalendars
|
||||||
edit { it.copy(agenda = options) }
|
it.copy(draft = it.draft.copy(hiddenCalendars = if (shown) hidden - id else hidden + id))
|
||||||
|
}
|
||||||
reloadPreview()
|
reloadPreview()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,14 +282,28 @@ class WidgetConfigViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadPreview(ready: WidgetConfigUiState.Ready): WidgetPreviewData = when (ready.kind) {
|
private suspend fun loadPreview(ready: WidgetConfigUiState.Ready): WidgetPreviewData {
|
||||||
WidgetKind.AGENDA -> {
|
val draft = ready.draft
|
||||||
val settings = prefs.widgetSettings.first()
|
val settings = prefs.widgetSettings.first()
|
||||||
WidgetPreviewData.Agenda(context.loadAgendaWidgetData(settings, requireNotNull(ready.agenda)))
|
return when (ready.kind) {
|
||||||
}
|
WidgetKind.AGENDA ->
|
||||||
WidgetKind.MONTH -> {
|
WidgetPreviewData.Agenda(context.loadAgendaWidgetData(settings, draft.content, draft.hiddenCalendars))
|
||||||
val index = monthIndexOf(today(systemZone()))
|
WidgetKind.MONTH -> {
|
||||||
WidgetPreviewData.Month(context.loadThemedMonthSource(monthWindowCenter(index)).content, index)
|
val index = monthIndexOf(today(systemZone()))
|
||||||
|
val source = context.loadThemedMonthSource(monthWindowCenter(index), draft.hiddenCalendars)
|
||||||
|
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.TODAY -> WidgetPreviewData.Days(context.loadTodayList(draft.loadContext(settings)).content)
|
||||||
|
WidgetKind.MONTH_DAY -> {
|
||||||
|
val index = monthIndexOf(today(systemZone()))
|
||||||
|
val source = context.loadThemedMonthSource(monthWindowCenter(index), draft.hiddenCalendars)
|
||||||
|
WidgetPreviewData.MonthDay(context.loadMonthDay(source, draft.content).content, index)
|
||||||
|
}
|
||||||
|
WidgetKind.COUNTDOWN ->
|
||||||
|
WidgetPreviewData.Countdown(context.loadCountdown(draft.loadContext(settings), draft.countdown, null).content)
|
||||||
|
WidgetKind.WEEK_STRIP -> WidgetPreviewData.Days(context.loadDays(draft.loadContext(settings), ::weekOf).content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,8 +316,10 @@ class WidgetConfigViewModel @Inject constructor(
|
|||||||
try {
|
try {
|
||||||
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId)
|
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId)
|
||||||
ready.kind.widget().updateWithRefresh(context, glanceId) { state ->
|
ready.kind.widget().updateWithRefresh(context, glanceId) { state ->
|
||||||
state.putAppearance(ready.appearance)
|
state.putAppearance(ready.draft.appearance)
|
||||||
ready.agenda?.let { state.putAgendaOptions(it) }
|
if (ready.kind.hasContentOptions) state.putAgendaOptions(ready.draft.content)
|
||||||
|
if (WidgetOption.CALENDARS in ready.kind.options) state.putHiddenCalendars(ready.draft.hiddenCalendars)
|
||||||
|
if (ready.kind == WidgetKind.COUNTDOWN) state.putCountdownOptions(ready.draft.countdown)
|
||||||
}
|
}
|
||||||
_events.send(WidgetConfigEvent.SAVED)
|
_events.send(WidgetConfigEvent.SAVED)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.countdown
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.datetime.daysUntil
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.math.ceil
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/** What a countdown widget counts down to (#439). */
|
||||||
|
enum class CountdownMode { NEXT, PINNED }
|
||||||
|
|
||||||
|
data class CountdownOptions(
|
||||||
|
val mode: CountdownMode = CountdownMode.NEXT,
|
||||||
|
/** The pinned event or series; a series follows its next occurrence. */
|
||||||
|
val eventId: Long? = null,
|
||||||
|
/** The pinned event's title when it was chosen, for the config screen. */
|
||||||
|
val title: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class CountdownUnit { NOW, MINUTES, HOURS, DAYS }
|
||||||
|
|
||||||
|
/** "in 25 min", "ends in 3 h", "in 12 days": [running] counts to the end instead of the start. */
|
||||||
|
data class CountdownLabel(val running: Boolean, val unit: CountdownUnit, val amount: Int)
|
||||||
|
|
||||||
|
private fun target(event: EventInstance, now: Instant): Instant = if (event.start <= now) event.end else event.start
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The label for [event] at [now]. Under a minute reads "now", under an hour
|
||||||
|
* counts minutes (rounded up), under a day whole hours (rounded), and beyond
|
||||||
|
* that calendar days in [zone], so "in 1 day" is always tomorrow.
|
||||||
|
*/
|
||||||
|
fun countdownLabel(event: EventInstance, now: Instant, zone: TimeZone): CountdownLabel {
|
||||||
|
val running = event.start <= now
|
||||||
|
val target = target(event, now)
|
||||||
|
val delta = target - now
|
||||||
|
return when {
|
||||||
|
delta < 1.minutes -> CountdownLabel(running, CountdownUnit.NOW, 0)
|
||||||
|
delta < 1.hours -> CountdownLabel(running, CountdownUnit.MINUTES, ceil(delta / 1.minutes).toInt())
|
||||||
|
delta < 24.hours -> CountdownLabel(running, CountdownUnit.HOURS, (delta / 1.hours).roundToInt().coerceAtLeast(1))
|
||||||
|
else -> {
|
||||||
|
val days = now.toLocalDateTime(zone).date.daysUntil(target.toLocalDateTime(zone).date)
|
||||||
|
CountdownLabel(running, CountdownUnit.DAYS, days.coerceAtLeast(1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When [countdownLabel] next reads differently, so the widget redraws then
|
||||||
|
* and not before: every minute in the last hour, at each half hour within the
|
||||||
|
* day, and at midnight or the 24-hour mark beyond that.
|
||||||
|
*/
|
||||||
|
fun nextLabelChange(event: EventInstance, now: Instant, zone: TimeZone): Instant {
|
||||||
|
val target = target(event, now)
|
||||||
|
val label = countdownLabel(event, now, zone)
|
||||||
|
val lowerBound: Duration = when (label.unit) {
|
||||||
|
CountdownUnit.NOW -> Duration.ZERO
|
||||||
|
CountdownUnit.MINUTES -> (label.amount - 1).minutes
|
||||||
|
CountdownUnit.HOURS -> maxOf((label.amount - 0.5).hours, 1.hours)
|
||||||
|
CountdownUnit.DAYS -> {
|
||||||
|
val midnight = now.toLocalDateTime(zone).date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
|
||||||
|
return minOf(midnight, target - 24.hours).coerceAtLeast(now + 1.minutes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (target - lowerBound).coerceAtLeast(now + 1.minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The next-event mode's pick: timed, not declined, not over; of those, the one
|
||||||
|
* whose countdown changes soonest — a running event's end, else a start.
|
||||||
|
*/
|
||||||
|
fun pickNextEvent(instances: List<EventInstance>, now: Instant): EventInstance? =
|
||||||
|
instances
|
||||||
|
.filter { !it.isAllDay && !it.isDeclined && it.end > now }
|
||||||
|
.minByOrNull { target(it, now) }
|
||||||
|
|
||||||
|
/** A pinned event's occurrence to count to: the running or next one. */
|
||||||
|
fun pickPinnedOccurrence(occurrences: List<EventInstance>, now: Instant, zone: TimeZone): EventInstance? =
|
||||||
|
occurrences.filter { it.counted(zone).end > now }.minByOrNull { it.start }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The span a countdown counts over. An all-day event is stored on UTC
|
||||||
|
* midnights; it starts and ends at local midnight of those dates instead.
|
||||||
|
*/
|
||||||
|
fun EventInstance.counted(zone: TimeZone): EventInstance = if (!isAllDay) this else copy(
|
||||||
|
start = start.toLocalDateTime(TimeZone.UTC).date.atStartOfDayIn(zone),
|
||||||
|
end = end.toLocalDateTime(TimeZone.UTC).date.atStartOfDayIn(zone),
|
||||||
|
)
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.countdown
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.datastore.preferences.core.MutablePreferences
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.glance.GlanceId
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.widget.Themed
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
|
||||||
|
import de.jeanlucmakiola.calendula.widget.hasCalendarPermission
|
||||||
|
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||||
|
import de.jeanlucmakiola.calendula.widget.today
|
||||||
|
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
|
||||||
|
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
|
||||||
|
import de.jeanlucmakiola.calendula.widget.widgetState
|
||||||
|
import de.jeanlucmakiola.calendula.widget.withoutCalendars
|
||||||
|
import de.jeanlucmakiola.floret.android.goAsync
|
||||||
|
import de.jeanlucmakiola.floret.glance.updateWithRefresh
|
||||||
|
import de.jeanlucmakiola.floret.prefs.toEnum
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration.Companion.days
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
private val ModeKey = stringPreferencesKey("cfg_countdown_mode")
|
||||||
|
private val EventKey = longPreferencesKey("cfg_countdown_event")
|
||||||
|
private val TitleKey = stringPreferencesKey("cfg_countdown_title")
|
||||||
|
|
||||||
|
internal val Preferences.countdownOptions: CountdownOptions
|
||||||
|
get() = CountdownOptions(this[ModeKey].toEnum(CountdownMode.NEXT), this[EventKey], this[TitleKey])
|
||||||
|
|
||||||
|
internal fun MutablePreferences.putCountdownOptions(options: CountdownOptions) {
|
||||||
|
this[ModeKey] = options.mode.name
|
||||||
|
if (options.eventId != null) this[EventKey] = options.eventId else remove(EventKey)
|
||||||
|
if (options.title != null) this[TitleKey] = options.title else remove(TitleKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How far ahead the next-event mode looks; past this it shows no event. */
|
||||||
|
private val NextEventHorizon = 30.days
|
||||||
|
|
||||||
|
/** How far ahead a pinned event's next occurrence is looked for. */
|
||||||
|
private val PinnedHorizon = 730.days
|
||||||
|
|
||||||
|
sealed interface CountdownData {
|
||||||
|
data object NeedsPermission : CountdownData
|
||||||
|
data class Ready(
|
||||||
|
val options: CountdownOptions,
|
||||||
|
/** The occurrence counted to; null when there is none to show. */
|
||||||
|
val event: EventInstance?,
|
||||||
|
val now: Instant,
|
||||||
|
val today: LocalDate,
|
||||||
|
val is24Hour: Boolean,
|
||||||
|
val soften: Boolean,
|
||||||
|
/** The placed widget, for opening its settings; null in a preview. */
|
||||||
|
val appWidgetId: Int?,
|
||||||
|
) : CountdownData
|
||||||
|
}
|
||||||
|
|
||||||
|
internal suspend fun Context.loadCountdown(
|
||||||
|
load: WidgetLoadContext,
|
||||||
|
options: CountdownOptions,
|
||||||
|
appWidgetId: Int?,
|
||||||
|
): Themed<CountdownData> {
|
||||||
|
val settings = load.settings
|
||||||
|
if (!hasCalendarPermission()) return Themed(CountdownData.NeedsPermission, settings.themeMode)
|
||||||
|
val now = Clock.System.now()
|
||||||
|
val repository = widgetEntryPoint().calendarRepository()
|
||||||
|
val event = when (options.mode) {
|
||||||
|
CountdownMode.NEXT ->
|
||||||
|
pickNextEvent(repository.instances(now..now + NextEventHorizon).first().withoutCalendars(load.hiddenCalendars), now)
|
||||||
|
CountdownMode.PINNED -> options.eventId?.let { id ->
|
||||||
|
pickPinnedOccurrence(repository.eventOccurrences(id, now - 1.days..now + PinnedHorizon), now, systemZone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Themed(
|
||||||
|
CountdownData.Ready(
|
||||||
|
options = options,
|
||||||
|
event = event,
|
||||||
|
now = now,
|
||||||
|
today = today(systemZone()),
|
||||||
|
is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)),
|
||||||
|
soften = settings.soften,
|
||||||
|
appWidgetId = appWidgetId,
|
||||||
|
),
|
||||||
|
settings.themeMode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [loadCountdown] for placed widget [id], arming its redraw for when the label next changes. */
|
||||||
|
internal suspend fun Context.loadPlacedCountdown(id: GlanceId): Themed<CountdownData> {
|
||||||
|
val appWidgetId = GlanceAppWidgetManager(this).getAppWidgetId(id)
|
||||||
|
val loaded = loadCountdown(widgetLoadContext(id), widgetState(id).countdownOptions, appWidgetId)
|
||||||
|
val event = (loaded.content as? CountdownData.Ready)?.event
|
||||||
|
if (event == null) {
|
||||||
|
CountdownTicks.cancel(this, appWidgetId)
|
||||||
|
} else {
|
||||||
|
val zone = systemZone()
|
||||||
|
CountdownTicks.schedule(this, appWidgetId, nextLabelChange(event.counted(zone), (loaded.content as CountdownData.Ready).now, zone))
|
||||||
|
}
|
||||||
|
return loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One alarm per countdown widget, for the moment its label changes. Not a
|
||||||
|
* wake-up alarm: with the screen off nobody sees the widget, so the redraw
|
||||||
|
* waits for the device to wake. Exact where allowed (the app holds the exact
|
||||||
|
* alarm permission for reminders), else within a minute.
|
||||||
|
*/
|
||||||
|
internal object CountdownTicks {
|
||||||
|
private const val ACTION_TICK = "de.jeanlucmakiola.calendula.widget.COUNTDOWN_TICK"
|
||||||
|
|
||||||
|
fun schedule(context: Context, appWidgetId: Int, at: Instant) {
|
||||||
|
val alarms = context.getSystemService(AlarmManager::class.java) ?: return
|
||||||
|
val intent = pendingIntent(context, appWidgetId)
|
||||||
|
val millis = at.toEpochMilliseconds()
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarms.canScheduleExactAlarms()) {
|
||||||
|
alarms.setExact(AlarmManager.RTC, millis, intent)
|
||||||
|
} else {
|
||||||
|
alarms.setWindow(AlarmManager.RTC, millis, 60_000L, intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel(context: Context, appWidgetId: Int) {
|
||||||
|
context.getSystemService(AlarmManager::class.java)?.cancel(pendingIntent(context, appWidgetId))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pendingIntent(context: Context, appWidgetId: Int): PendingIntent = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
0,
|
||||||
|
Intent(context, CountdownTickReceiver::class.java)
|
||||||
|
.setAction(ACTION_TICK)
|
||||||
|
// Keys the PendingIntent per widget.
|
||||||
|
.setData("calendula://countdown/$appWidgetId".toUri())
|
||||||
|
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reloads the countdown widget whose label just changed; the load arms the next tick. */
|
||||||
|
class CountdownTickReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
|
||||||
|
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) return
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
goAsync {
|
||||||
|
try {
|
||||||
|
val glanceId = GlanceAppWidgetManager(appContext).getGlanceIdBy(appWidgetId)
|
||||||
|
CountdownWidget().updateWithRefresh(appContext, glanceId)
|
||||||
|
} catch (_: IllegalArgumentException) {
|
||||||
|
// The widget is gone.
|
||||||
|
CountdownTicks.cancel(appContext, appWidgetId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.countdown
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||||
|
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||||
|
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.Resources
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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.action.Action
|
||||||
|
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.cornerRadius
|
||||||
|
import androidx.glance.appwidget.provideContent
|
||||||
|
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.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.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
|
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
|
||||||
|
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||||
|
import de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity
|
||||||
|
import de.jeanlucmakiola.calendula.widget.formatWidgetTime
|
||||||
|
import de.jeanlucmakiola.calendula.widget.relativeDayLabel
|
||||||
|
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||||
|
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||||
|
import de.jeanlucmakiola.calendula.widget.widgetCardColor
|
||||||
|
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.themedColor
|
||||||
|
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.minus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Countdown (#439): to the next timed event, or to a chosen event or series
|
||||||
|
* ("Vacation · in 12 days"). Redraws when its label changes, see
|
||||||
|
* [nextLabelChange] and [CountdownTicks]. Tapping opens the event.
|
||||||
|
*/
|
||||||
|
class CountdownWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
|
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||||
|
|
||||||
|
override val sizeMode = SizeMode.Single
|
||||||
|
|
||||||
|
override val previewSizeMode = previewSizeOf(WidgetKind.COUNTDOWN)
|
||||||
|
|
||||||
|
override suspend fun providePreview(context: Context, widgetCategory: Int) =
|
||||||
|
provideSamplePreview(context, WidgetKind.COUNTDOWN)
|
||||||
|
|
||||||
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
|
val widgetData = WidgetData(context.loadPlacedCountdown(id), refreshStamp(context, id))
|
||||||
|
provideContent {
|
||||||
|
val loaded = widgetData.current(currentRefreshStamp()) { context.loadPlacedCountdown(id) }
|
||||||
|
CountdownWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CountdownWidgetReceiver : CalendulaWidgetReceiver() {
|
||||||
|
override val glanceAppWidget: GlanceAppWidget = CountdownWidget()
|
||||||
|
|
||||||
|
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
|
||||||
|
super.onDeleted(context, appWidgetIds)
|
||||||
|
appWidgetIds.forEach { CountdownTicks.cancel(context, it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun CountdownWidgetContent(data: CountdownData, appearance: WidgetAppearance, appTheme: ThemeMode) {
|
||||||
|
CalendulaWidgetRoot(appearance, appTheme) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
when (data) {
|
||||||
|
CountdownData.NeedsPermission -> WidgetMessage(context.getString(R.string.widget_needs_permission))
|
||||||
|
is CountdownData.Ready -> {
|
||||||
|
val event = data.event
|
||||||
|
when {
|
||||||
|
event != null -> EventCountdown(event, data)
|
||||||
|
data.options.mode == CountdownMode.PINNED && data.options.eventId == null ->
|
||||||
|
Message(context.getString(R.string.widget_countdown_choose), configAction(context, data.appWidgetId))
|
||||||
|
data.options.mode == CountdownMode.PINNED ->
|
||||||
|
Message(context.getString(R.string.widget_countdown_over, data.options.title.orEmpty()), configAction(context, data.appWidgetId))
|
||||||
|
else -> Message(
|
||||||
|
context.getString(R.string.widget_upcoming_empty),
|
||||||
|
actionStartActivity(MainActivity.openViewIntent(context, null)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun configAction(context: Context, appWidgetId: Int?): Action? =
|
||||||
|
appWidgetId?.takeIf { it != AppWidgetManager.INVALID_APPWIDGET_ID }
|
||||||
|
?.let { actionStartActivity(WidgetConfigActivity.intent(context, it)) }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Message(text: String, action: Action?) {
|
||||||
|
val modifier = if (action != null) GlanceModifier.clickable(action) else GlanceModifier
|
||||||
|
WidgetMessage(text, modifier = modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EventCountdown(event: EventInstance, data: CountdownData.Ready) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val label = countdownLabel(event.counted(systemZone()), data.now, systemZone())
|
||||||
|
val open = actionStartActivity(
|
||||||
|
MainActivity.openEventIntent(
|
||||||
|
context = context,
|
||||||
|
eventId = event.eventId,
|
||||||
|
beginMillis = event.start.toEpochMilliseconds(),
|
||||||
|
endMillis = event.end.toEpochMilliseconds(),
|
||||||
|
source = CalendarView.Agenda,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// Over a wallpaper the text sits on its own card, as every other widget's rows do.
|
||||||
|
val card = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
|
||||||
|
GlanceModifier.cornerRadius(16.dp).background(widgetCardColor())
|
||||||
|
} else {
|
||||||
|
GlanceModifier
|
||||||
|
}
|
||||||
|
Box(modifier = GlanceModifier.fillMaxSize().padding(6.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = card.fillMaxSize().clickable(open).padding(horizontal = 10.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.width(4.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.cornerRadius(2.dp)
|
||||||
|
.background(
|
||||||
|
themedColor(
|
||||||
|
day = eventAccent(event.color, dark = false, data.soften),
|
||||||
|
night = eventAccent(event.color, dark = true, data.soften),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
) {}
|
||||||
|
Spacer(GlanceModifier.width(10.dp))
|
||||||
|
Column(modifier = GlanceModifier.defaultWeight(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp, fontWeight = FontWeight.Medium),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = countdownText(context.resources, label),
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(color = GlanceTheme.colors.primary, fontSize = 22.sp, fontWeight = FontWeight.Bold),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = whenText(context, event, data),
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun countdownText(res: Resources, label: CountdownLabel): String = when (label.unit) {
|
||||||
|
CountdownUnit.NOW -> res.getString(if (label.running) R.string.widget_countdown_ending_now else R.string.widget_countdown_now)
|
||||||
|
CountdownUnit.MINUTES -> res.getQuantityString(
|
||||||
|
if (label.running) R.plurals.widget_countdown_ends_minutes else R.plurals.widget_countdown_in_minutes,
|
||||||
|
label.amount,
|
||||||
|
label.amount,
|
||||||
|
)
|
||||||
|
CountdownUnit.HOURS -> res.getQuantityString(
|
||||||
|
if (label.running) R.plurals.widget_countdown_ends_hours else R.plurals.widget_countdown_in_hours,
|
||||||
|
label.amount,
|
||||||
|
label.amount,
|
||||||
|
)
|
||||||
|
CountdownUnit.DAYS -> when {
|
||||||
|
label.amount == 1 && label.running -> res.getString(R.string.widget_countdown_ends_tomorrow)
|
||||||
|
label.amount == 1 -> res.getString(R.string.widget_countdown_tomorrow)
|
||||||
|
else -> res.getQuantityString(
|
||||||
|
if (label.running) R.plurals.widget_countdown_ends_days else R.plurals.widget_countdown_in_days,
|
||||||
|
label.amount,
|
||||||
|
label.amount,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** When it starts, or ends once running: "Today · 14:00", "Fri · All day". */
|
||||||
|
private fun whenText(context: Context, event: EventInstance, data: CountdownData.Ready): String {
|
||||||
|
val counted = event.counted(systemZone())
|
||||||
|
val running = counted.start <= data.now
|
||||||
|
val at = if (running) counted.end else counted.start
|
||||||
|
val date = at.toLocalDateTime(systemZone()).date
|
||||||
|
val day = relativeDayLabel(context, date, data.today)
|
||||||
|
return when {
|
||||||
|
event.isAllDay && running -> context.getString(
|
||||||
|
R.string.widget_countdown_until,
|
||||||
|
relativeDayLabel(context, date.minus(1, DateTimeUnit.DAY), data.today),
|
||||||
|
)
|
||||||
|
event.isAllDay -> "$day · ${context.getString(R.string.event_detail_all_day)}"
|
||||||
|
running -> context.getString(R.string.agenda_span_ends, "$day · ${formatWidgetTime(at, data.is24Hour)}")
|
||||||
|
else -> "$day · ${formatWidgetTime(at, data.is24Hour)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.date
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||||
|
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||||
|
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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.Column
|
||||||
|
import androidx.glance.layout.Spacer
|
||||||
|
import androidx.glance.layout.fillMaxSize
|
||||||
|
import androidx.glance.layout.height
|
||||||
|
import androidx.glance.layout.padding
|
||||||
|
import androidx.glance.semantics.contentDescription
|
||||||
|
import androidx.glance.semantics.semantics
|
||||||
|
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||||
|
import androidx.glance.text.FontWeight
|
||||||
|
import androidx.glance.text.Text
|
||||||
|
import androidx.glance.text.TextAlign
|
||||||
|
import androidx.glance.text.TextStyle
|
||||||
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
|
||||||
|
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||||
|
import de.jeanlucmakiola.calendula.widget.loadToday
|
||||||
|
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||||
|
import de.jeanlucmakiola.calendula.widget.widgetLocale
|
||||||
|
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetData
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetShape
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetShapedBox
|
||||||
|
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
|
||||||
|
import de.jeanlucmakiola.floret.glance.refreshStamp
|
||||||
|
import de.jeanlucmakiola.floret.glance.wallpaperBacking
|
||||||
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
|
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.toJavaDayOfWeek
|
||||||
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
|
|
||||||
|
/** 1×1 date (#376): today's weekday above its number on an expressive shape; opens today. */
|
||||||
|
class DateWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
|
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||||
|
|
||||||
|
override val sizeMode = SizeMode.Exact
|
||||||
|
|
||||||
|
override val previewSizeMode = previewSizeOf(WidgetKind.DATE)
|
||||||
|
|
||||||
|
override suspend fun providePreview(context: Context, widgetCategory: Int) =
|
||||||
|
provideSamplePreview(context, WidgetKind.DATE)
|
||||||
|
|
||||||
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
|
val widgetData = WidgetData(context.loadToday(), refreshStamp(context, id))
|
||||||
|
provideContent {
|
||||||
|
val loaded = widgetData.current(currentRefreshStamp()) { context.loadToday() }
|
||||||
|
DateWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DateWidgetReceiver : CalendulaWidgetReceiver() {
|
||||||
|
override val glanceAppWidget: GlanceAppWidget = DateWidget()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val WeekdayLine = 18.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DateWidgetContent(today: LocalDate, appearance: WidgetAppearance, appTheme: ThemeMode) {
|
||||||
|
CalendulaWidgetRoot(appearance, appTheme) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val size = LocalSize.current
|
||||||
|
val fontScale = context.resources.configuration.fontScale
|
||||||
|
val side = (minOf(size.width, size.height - WeekdayLine * fontScale) - 8.dp).coerceIn(32.dp, 120.dp)
|
||||||
|
val label = localizedDateFormatter(widgetLocale(), "EEEEdMMMM").format(today.toJavaLocalDate())
|
||||||
|
val weekday = TextStyle(
|
||||||
|
color = GlanceTheme.colors.onSurface,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.clickable(actionStartActivity(MainActivity.openDateIntent(context, today, CalendarView.Day)))
|
||||||
|
.semantics { contentDescription = label },
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
val name = today.dayOfWeek.toJavaDayOfWeek().getDisplayName(JavaTextStyle.SHORT, widgetLocale())
|
||||||
|
val backing = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
|
||||||
|
GlanceModifier.wallpaperBacking(radius = 8.dp).padding(horizontal = 6.dp)
|
||||||
|
} else {
|
||||||
|
GlanceModifier
|
||||||
|
}
|
||||||
|
Text(text = name.uppercase(widgetLocale()), maxLines = 1, style = weekday, modifier = backing)
|
||||||
|
Spacer(GlanceModifier.height(2.dp))
|
||||||
|
WidgetShapedBox(shape = WidgetShape.COOKIE, color = GlanceTheme.colors.primary, size = side) {
|
||||||
|
Text(
|
||||||
|
text = today.day.toString(),
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(
|
||||||
|
color = GlanceTheme.colors.onPrimary,
|
||||||
|
// Sized to the shape, so held against the font scale.
|
||||||
|
fontSize = (side.value * 0.45f / fontScale).sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.month
|
package de.jeanlucmakiola.calendula.widget.month
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||||
|
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||||
|
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
@@ -43,7 +46,6 @@ import androidx.glance.layout.fillMaxWidth
|
|||||||
import androidx.glance.layout.height
|
import androidx.glance.layout.height
|
||||||
import androidx.glance.layout.padding
|
import androidx.glance.layout.padding
|
||||||
import androidx.glance.layout.size
|
import androidx.glance.layout.size
|
||||||
import androidx.glance.layout.width
|
|
||||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||||
import de.jeanlucmakiola.floret.glance.LocalWidgetThemeMode
|
import de.jeanlucmakiola.floret.glance.LocalWidgetThemeMode
|
||||||
import de.jeanlucmakiola.floret.glance.WidgetColorRole
|
import de.jeanlucmakiola.floret.glance.WidgetColorRole
|
||||||
@@ -136,6 +138,11 @@ class MonthWidget : GlanceAppWidget() {
|
|||||||
*/
|
*/
|
||||||
override val sizeMode = SizeMode.Exact
|
override val sizeMode = SizeMode.Exact
|
||||||
|
|
||||||
|
override val previewSizeMode = previewSizeOf(WidgetKind.MONTH)
|
||||||
|
|
||||||
|
override suspend fun providePreview(context: Context, widgetCategory: Int) =
|
||||||
|
provideSamplePreview(context, WidgetKind.MONTH)
|
||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val shown = getAppWidgetState<Preferences>(context, id)[MONTH_INDEX_KEY]
|
val shown = getAppWidgetState<Preferences>(context, id)[MONTH_INDEX_KEY]
|
||||||
?: currentMonthIndex(systemZone())
|
?: currentMonthIndex(systemZone())
|
||||||
@@ -239,8 +246,6 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) {
|
|||||||
is MonthWidgetSource.Ready -> {
|
is MonthWidgetSource.Ready -> {
|
||||||
val zone = systemZone()
|
val zone = systemZone()
|
||||||
val ym = yearMonthOf(index)
|
val ym = yearMonthOf(index)
|
||||||
// Column width from the live widget size, minus our H padding.
|
|
||||||
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
|
|
||||||
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
|
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val fontScale = context.resources.configuration.fontScale
|
val fontScale = context.resources.configuration.fontScale
|
||||||
@@ -261,7 +266,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) {
|
|||||||
// each carry their own fill, so a transparent widget stays
|
// each carry their own fill, so a transparent widget stays
|
||||||
// readable without one blob over the wallpaper (#292).
|
// readable without one blob over the wallpaper (#292).
|
||||||
Column(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) {
|
Column(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) {
|
||||||
WeekdayHeader(weekStart = source.weekStart, colW = colW)
|
WeekdayHeader(weekStart = source.weekStart)
|
||||||
AndroidRemoteViews(
|
AndroidRemoteViews(
|
||||||
remoteViews = monthGridViews(context, layouts, ym.month, source.today, source.soften, colors, mode),
|
remoteViews = monthGridViews(context, layouts, ym.month, source.today, source.soften, colors, mode),
|
||||||
modifier = GlanceModifier.fillMaxWidth().defaultWeight(),
|
modifier = GlanceModifier.fillMaxWidth().defaultWeight(),
|
||||||
@@ -315,10 +320,10 @@ private fun MonthHeader(label: String, today: LocalDate) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WeekdayHeader(weekStart: DayOfWeek, colW: Dp) {
|
private fun WeekdayHeader(weekStart: DayOfWeek) {
|
||||||
Row(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 1.dp)) {
|
Row(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 1.dp)) {
|
||||||
weekdayNarrowNames(weekStart).forEach { name ->
|
weekdayNarrowNames(weekStart).forEach { name ->
|
||||||
Box(modifier = GlanceModifier.width(colW).padding(horizontal = 1.dp)) {
|
Box(modifier = GlanceModifier.defaultWeight().padding(horizontal = 1.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = name,
|
text = name,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
|
|||||||
@@ -1,56 +1,9 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.month
|
package de.jeanlucmakiola.calendula.widget.month
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.appwidget.AppWidgetManager
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.glance.appwidget.GlanceAppWidget
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
|
||||||
import de.jeanlucmakiola.calendula.widget.syncWidgetRollover
|
|
||||||
import de.jeanlucmakiola.floret.glance.reloadOnUpdate
|
|
||||||
import de.jeanlucmakiola.floret.glance.restoreState
|
|
||||||
|
|
||||||
/**
|
/** Host-facing receiver for the month widget; rendering is [MonthWidget]'s. */
|
||||||
* Host-facing receiver for the month widget. Declared in the manifest with the
|
class MonthWidgetReceiver : CalendulaWidgetReceiver() {
|
||||||
* `appwidget_info_month` provider metadata; delegates rendering to [MonthWidget].
|
|
||||||
*/
|
|
||||||
class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
|
|
||||||
override val glanceAppWidget: GlanceAppWidget = MonthWidget()
|
override val glanceAppWidget: GlanceAppWidget = MonthWidget()
|
||||||
|
|
||||||
/** First month widget placed — start rolling "today" over at midnight (#228). */
|
|
||||||
override fun onEnabled(context: Context) {
|
|
||||||
super.onEnabled(context)
|
|
||||||
syncWidgetRollover(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Last month widget removed. [syncWidgetRollover] cancels only if
|
|
||||||
* no agenda widget is left either.
|
|
||||||
*/
|
|
||||||
override fun onDisabled(context: Context) {
|
|
||||||
super.onDisabled(context)
|
|
||||||
syncWidgetRollover(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A backup restore or device transfer renumbered the widgets: keep each one's settings (#292). */
|
|
||||||
override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) {
|
|
||||||
restoreState(context, oldWidgetIds, newWidgetIds)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The `updatePeriodMillis` backstop is the one wake-up the *system* still
|
|
||||||
* owns, so it doubles as the alarm's self-heal: anything that drops a
|
|
||||||
* pending alarm without a broadcast (force-stop, battery restriction, an OEM
|
|
||||||
* freeze) is repaired here rather than on the next app open. Re-arming
|
|
||||||
* closer to midnight also narrows the inexact delivery window.
|
|
||||||
*/
|
|
||||||
@SuppressLint("MissingSuperCall")
|
|
||||||
override fun onUpdate(
|
|
||||||
context: Context,
|
|
||||||
appWidgetManager: AppWidgetManager,
|
|
||||||
appWidgetIds: IntArray,
|
|
||||||
) {
|
|
||||||
// In place of super, which only recomposes and would take the goAsync handle.
|
|
||||||
reloadOnUpdate(context, appWidgetIds)
|
|
||||||
syncWidgetRollover(context)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.quickadd
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||||
|
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||||
|
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.glance.GlanceId
|
||||||
|
import androidx.glance.GlanceModifier
|
||||||
|
import androidx.glance.GlanceTheme
|
||||||
|
import androidx.glance.ImageProvider
|
||||||
|
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.fillMaxSize
|
||||||
|
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||||
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||||
|
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
|
||||||
|
import de.jeanlucmakiola.calendula.widget.loadToday
|
||||||
|
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetData
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetShape
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetShapedIconButton
|
||||||
|
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
|
||||||
|
import de.jeanlucmakiola.floret.glance.refreshStamp
|
||||||
|
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1×1 quick add (#435): one shaped "+" that opens the new-event editor on
|
||||||
|
* today, with the same defaults as the app's own new-event button.
|
||||||
|
*/
|
||||||
|
class QuickAddWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
|
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||||
|
|
||||||
|
// Exact: the shape fills whatever square the launcher gives it.
|
||||||
|
override val sizeMode = SizeMode.Exact
|
||||||
|
|
||||||
|
override val previewSizeMode = previewSizeOf(WidgetKind.QUICK_ADD)
|
||||||
|
|
||||||
|
override suspend fun providePreview(context: Context, widgetCategory: Int) =
|
||||||
|
provideSamplePreview(context, WidgetKind.QUICK_ADD)
|
||||||
|
|
||||||
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
|
val widgetData = WidgetData(context.loadToday(), refreshStamp(context, id))
|
||||||
|
provideContent {
|
||||||
|
// The rollover moves the stamp, so the editor opens on the new day.
|
||||||
|
val loaded = widgetData.current(currentRefreshStamp()) { context.loadToday() }
|
||||||
|
QuickAddWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class QuickAddWidgetReceiver : CalendulaWidgetReceiver() {
|
||||||
|
override val glanceAppWidget: GlanceAppWidget = QuickAddWidget()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun QuickAddWidgetContent(today: LocalDate, appearance: WidgetAppearance, appTheme: ThemeMode) {
|
||||||
|
CalendulaWidgetRoot(appearance, appTheme) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val size = LocalSize.current
|
||||||
|
val action = actionStartActivity(MainActivity.openCreateIntent(context, today))
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier.fillMaxSize().clickable(action),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
WidgetShapedIconButton(
|
||||||
|
shape = WidgetShape.SUNNY,
|
||||||
|
icon = ImageProvider(R.drawable.ic_widget_add),
|
||||||
|
contentDescription = context.getString(R.string.widget_new_event),
|
||||||
|
action = action,
|
||||||
|
color = GlanceTheme.colors.primary,
|
||||||
|
iconTint = GlanceTheme.colors.onPrimary,
|
||||||
|
size = (minOf(size.width, size.height) - 12.dp).coerceIn(36.dp, 120.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.split
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||||
|
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||||
|
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
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.datastore.preferences.core.intPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.glance.GlanceId
|
||||||
|
import androidx.glance.GlanceModifier
|
||||||
|
import androidx.glance.GlanceTheme
|
||||||
|
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.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
|
||||||
|
import androidx.glance.appwidget.lazy.items
|
||||||
|
import androidx.glance.appwidget.provideContent
|
||||||
|
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
|
||||||
|
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.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.data.prefs.PastEventDisplay
|
||||||
|
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||||
|
import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
|
||||||
|
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
|
||||||
|
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
|
||||||
|
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
||||||
|
import de.jeanlucmakiola.calendula.widget.Themed
|
||||||
|
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.loadCachedMonthSource
|
||||||
|
import de.jeanlucmakiola.calendula.widget.monthIndexOf
|
||||||
|
import de.jeanlucmakiola.calendula.widget.monthWindowCenter
|
||||||
|
import de.jeanlucmakiola.calendula.widget.relativeDayLabel
|
||||||
|
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||||
|
import de.jeanlucmakiola.calendula.widget.today
|
||||||
|
import de.jeanlucmakiola.calendula.widget.weekOf
|
||||||
|
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||||
|
import de.jeanlucmakiola.calendula.widget.widgetLocale
|
||||||
|
import de.jeanlucmakiola.calendula.widget.widgetCardColor
|
||||||
|
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
|
||||||
|
import de.jeanlucmakiola.calendula.widget.widgetSettings
|
||||||
|
import de.jeanlucmakiola.calendula.widget.widgetState
|
||||||
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
|
import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetActionStyle
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetData
|
||||||
|
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 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.coroutines.flow.first
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toJavaDayOfWeek
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
|
|
||||||
|
/** Per-widget state: the shown month (`year * 12 + monthOrdinal`) and the selected day; both follow today when absent. */
|
||||||
|
private val MONTH_INDEX_KEY = intPreferencesKey("split_month_index")
|
||||||
|
private val SELECTED_KEY = stringPreferencesKey("split_selected")
|
||||||
|
|
||||||
|
/** What the month + day widget draws: the month widget's window, plus what its list needs. */
|
||||||
|
sealed interface MonthDayData {
|
||||||
|
data object NeedsPermission : MonthDayData
|
||||||
|
data class Ready(
|
||||||
|
val source: MonthWidgetSource.Ready,
|
||||||
|
val options: AgendaWidgetOptions,
|
||||||
|
val is24Hour: Boolean,
|
||||||
|
val now: Instant,
|
||||||
|
) : MonthDayData
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [loaded]'s month window with what the day list draws by. */
|
||||||
|
internal suspend fun Context.loadMonthDay(loaded: Themed<MonthWidgetSource>, options: AgendaWidgetOptions): Themed<MonthDayData> {
|
||||||
|
val ready = loaded.content as? MonthWidgetSource.Ready ?: return Themed(MonthDayData.NeedsPermission, loaded.appTheme)
|
||||||
|
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
|
||||||
|
return Themed(
|
||||||
|
MonthDayData.Ready(
|
||||||
|
source = ready,
|
||||||
|
options = options,
|
||||||
|
is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)),
|
||||||
|
now = Clock.System.now(),
|
||||||
|
),
|
||||||
|
loaded.appTheme,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Month + day (#441): a compact month grid beside the selected day's events,
|
||||||
|
* like Samsung Calendar's. A day in the grid selects it, starting on today;
|
||||||
|
* the list and its events open the app. The plain month widget keeps
|
||||||
|
* tap-opens-the-day (#386).
|
||||||
|
*/
|
||||||
|
class MonthDayWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
|
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||||
|
|
||||||
|
override val sizeMode = SizeMode.Exact
|
||||||
|
|
||||||
|
override val previewSizeMode = previewSizeOf(WidgetKind.MONTH_DAY)
|
||||||
|
|
||||||
|
override suspend fun providePreview(context: Context, widgetCategory: Int) =
|
||||||
|
provideSamplePreview(context, WidgetKind.MONTH_DAY)
|
||||||
|
|
||||||
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
|
val stamp = refreshStamp(context, id)
|
||||||
|
val shown = context.widgetState(id)[MONTH_INDEX_KEY] ?: monthIndexOf(today(systemZone()))
|
||||||
|
val center = monthWindowCenter(shown)
|
||||||
|
val load: suspend (Int, Long) -> Themed<MonthDayData> = { c, s ->
|
||||||
|
context.loadMonthDay(context.loadCachedMonthSource(id, c, s), context.widgetLoadContext(id).options)
|
||||||
|
}
|
||||||
|
val widgetData = WidgetData(load(center, stamp), center, stamp)
|
||||||
|
provideContent {
|
||||||
|
val index = currentState(MONTH_INDEX_KEY) ?: monthIndexOf(today(systemZone()))
|
||||||
|
val currentCenter = monthWindowCenter(index)
|
||||||
|
val currentStamp = currentRefreshStamp()
|
||||||
|
val loaded = widgetData.current(currentCenter, currentStamp) { load(currentCenter, currentStamp) }
|
||||||
|
val selected = currentState(SELECTED_KEY)?.let(LocalDate::parse)
|
||||||
|
MonthDayWidgetContent(loaded.content, index, selected, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MonthDayWidgetReceiver : CalendulaWidgetReceiver() {
|
||||||
|
override val glanceAppWidget: GlanceAppWidget = MonthDayWidget()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Selects the tapped day; the list redraws from what's loaded. */
|
||||||
|
class SelectDayAction : ActionCallback {
|
||||||
|
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||||
|
val date = parameters[dateKey] ?: return
|
||||||
|
updateAppWidgetState(context, glanceId) { prefs ->
|
||||||
|
if (date == today(systemZone()).toString()) prefs.remove(SELECTED_KEY) else prefs[SELECTED_KEY] = date
|
||||||
|
}
|
||||||
|
MonthDayWidget().update(context.applicationContext, glanceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val dateKey = ActionParameters.Key<String>("date")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pages the grid by the `delta` parameter; landing on the current month follows the date again. */
|
||||||
|
class ShiftMonthDayAction : ActionCallback {
|
||||||
|
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||||
|
val delta = parameters[deltaKey] ?: 0
|
||||||
|
val current = monthIndexOf(today(systemZone()))
|
||||||
|
updateAppWidgetState(context, glanceId) { prefs ->
|
||||||
|
val next = (prefs[MONTH_INDEX_KEY] ?: current) + delta
|
||||||
|
if (next == current) prefs.remove(MONTH_INDEX_KEY) else prefs[MONTH_INDEX_KEY] = next
|
||||||
|
}
|
||||||
|
MonthDayWidget().update(context.applicationContext, glanceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val deltaKey = ActionParameters.Key<Int>("delta")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Back to this month with today selected. */
|
||||||
|
class ResetMonthDayAction : ActionCallback {
|
||||||
|
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||||
|
updateAppWidgetState(context, glanceId) { prefs ->
|
||||||
|
prefs.remove(MONTH_INDEX_KEY)
|
||||||
|
prefs.remove(SELECTED_KEY)
|
||||||
|
}
|
||||||
|
MonthDayWidget().update(context.applicationContext, glanceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The grid's days: whole weeks from [weekStart] covering [index]'s month. */
|
||||||
|
internal fun monthGridDays(index: Int, weekStart: DayOfWeek): List<LocalDate> {
|
||||||
|
val first = LocalDate(index / 12, index % 12 + 1, 1)
|
||||||
|
val start = weekOf(first, weekStart).start
|
||||||
|
val last = first.plus(1, DateTimeUnit.MONTH).plus(-1, DateTimeUnit.DAY)
|
||||||
|
val weeks = (start.daysUntilInclusive(last) + 6) / 7
|
||||||
|
return List(weeks * 7) { start.plus(it, DateTimeUnit.DAY) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LocalDate.daysUntilInclusive(other: LocalDate): Int =
|
||||||
|
(other.toEpochDays() - toEpochDays()).toInt() + 1
|
||||||
|
|
||||||
|
private val GridGap = 8.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun MonthDayWidgetContent(
|
||||||
|
data: MonthDayData,
|
||||||
|
index: Int,
|
||||||
|
selected: LocalDate?,
|
||||||
|
appearance: WidgetAppearance,
|
||||||
|
appTheme: ThemeMode,
|
||||||
|
preview: Boolean = false,
|
||||||
|
) {
|
||||||
|
CalendulaWidgetRoot(appearance, appTheme) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
when (data) {
|
||||||
|
MonthDayData.NeedsPermission -> WidgetMessage(context.getString(R.string.widget_needs_permission))
|
||||||
|
is MonthDayData.Ready -> {
|
||||||
|
val source = data.source
|
||||||
|
val day = selected ?: source.today
|
||||||
|
val zone = systemZone()
|
||||||
|
val days = monthGridDays(index, source.weekStart)
|
||||||
|
val busy = groupAgendaDays(days.first(), days.last(), source.instances, zone).map { it.date }.toSet()
|
||||||
|
Column(modifier = GlanceModifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 6.dp)) {
|
||||||
|
Header(index, source.today)
|
||||||
|
Spacer(GlanceModifier.height(4.dp))
|
||||||
|
Row(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) {
|
||||||
|
val gridWidth = (LocalSize.current.width - 16.dp - GridGap) * 0.52f
|
||||||
|
Box(modifier = GlanceModifier.width(gridWidth).fillMaxHeight()) {
|
||||||
|
Grid(days, index, source, day, busy, gridWidth)
|
||||||
|
}
|
||||||
|
Spacer(GlanceModifier.width(GridGap))
|
||||||
|
Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight()) {
|
||||||
|
DayList(day, data, preview)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Header(index: Int, today: LocalDate) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
WidgetHeader(
|
||||||
|
title = formatCalendarTitle(
|
||||||
|
date = java.time.LocalDate.of(index / 12, index % 12 + 1, 1),
|
||||||
|
locale = widgetLocale(),
|
||||||
|
currentYear = today.year,
|
||||||
|
skeleton = "LLLL",
|
||||||
|
),
|
||||||
|
titleAction = actionStartActivity(MainActivity.openViewIntent(context, CalendarView.Month)),
|
||||||
|
modifier = GlanceModifier.padding(bottom = 2.dp),
|
||||||
|
titleInset = 4.dp,
|
||||||
|
) {
|
||||||
|
WidgetHeaderAction(
|
||||||
|
icon = ImageProvider(R.drawable.ic_widget_chevron_left),
|
||||||
|
contentDescription = context.getString(R.string.widget_prev_month),
|
||||||
|
action = actionRunCallback<ShiftMonthDayAction>(actionParametersOf(ShiftMonthDayAction.deltaKey to -1)),
|
||||||
|
style = WidgetActionStyle.TONAL,
|
||||||
|
)
|
||||||
|
WidgetHeaderActionGap()
|
||||||
|
WidgetHeaderTextAction(
|
||||||
|
text = today.day.toString(),
|
||||||
|
contentDescription = context.getString(R.string.widget_today),
|
||||||
|
action = actionRunCallback<ResetMonthDayAction>(),
|
||||||
|
style = WidgetActionStyle.TONAL,
|
||||||
|
)
|
||||||
|
WidgetHeaderActionGap()
|
||||||
|
WidgetHeaderAction(
|
||||||
|
icon = ImageProvider(R.drawable.ic_widget_chevron_right),
|
||||||
|
contentDescription = context.getString(R.string.widget_next_month),
|
||||||
|
action = actionRunCallback<ShiftMonthDayAction>(actionParametersOf(ShiftMonthDayAction.deltaKey to 1)),
|
||||||
|
style = WidgetActionStyle.TONAL,
|
||||||
|
)
|
||||||
|
WidgetHeaderActionGap()
|
||||||
|
WidgetHeaderAction(
|
||||||
|
icon = ImageProvider(R.drawable.ic_widget_add),
|
||||||
|
contentDescription = context.getString(R.string.widget_new_event),
|
||||||
|
action = actionStartActivity(MainActivity.openCreateIntent(context, today)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The month as numbers: selected day filled, today in bold primary, days
|
||||||
|
* with events dotted, days around the month faded. Three views a day keeps
|
||||||
|
* a six-week month well inside Glance's view ids (#372).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun Grid(
|
||||||
|
days: List<LocalDate>,
|
||||||
|
index: Int,
|
||||||
|
source: MonthWidgetSource.Ready,
|
||||||
|
selected: LocalDate,
|
||||||
|
busy: Set<LocalDate>,
|
||||||
|
width: Dp,
|
||||||
|
) {
|
||||||
|
val locale = widgetLocale()
|
||||||
|
val month = index % 12 + 1
|
||||||
|
val backing = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
|
||||||
|
GlanceModifier.cornerRadius(14.dp).background(widgetCardColor())
|
||||||
|
} else {
|
||||||
|
GlanceModifier
|
||||||
|
}
|
||||||
|
Column(modifier = backing.fillMaxSize().padding(2.dp)) {
|
||||||
|
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
|
for (i in 0 until 7) {
|
||||||
|
Text(
|
||||||
|
text = java.time.DayOfWeek.of((source.weekStart.ordinal + i) % 7 + 1).getDisplayName(JavaTextStyle.NARROW, locale),
|
||||||
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 10.sp, textAlign = TextAlign.Center),
|
||||||
|
modifier = GlanceModifier.width((width - 4.dp) / 7),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
days.chunked(7).forEach { week ->
|
||||||
|
Row(modifier = GlanceModifier.fillMaxWidth().defaultWeight(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
week.forEach { date ->
|
||||||
|
DayCell(date, date.month.ordinal + 1 == month, date == source.today, date == selected, date in busy, (width - 4.dp) / 7)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DayCell(date: LocalDate, inMonth: Boolean, isToday: Boolean, isSelected: Boolean, hasEvents: Boolean, width: Dp) {
|
||||||
|
val ink = when {
|
||||||
|
isSelected -> GlanceTheme.colors.onPrimary
|
||||||
|
isToday -> GlanceTheme.colors.primary
|
||||||
|
inMonth -> GlanceTheme.colors.onSurface
|
||||||
|
else -> GlanceTheme.colors.outline
|
||||||
|
}
|
||||||
|
// Always set: the host reapplies onto reused views, so a dropped background would linger.
|
||||||
|
val fill = if (isSelected) GlanceTheme.colors.primary else ColorProvider(Color.Transparent)
|
||||||
|
Column(
|
||||||
|
modifier = GlanceModifier.cornerRadius(10.dp).background(fill)
|
||||||
|
.width(width)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.clickable(actionRunCallback<SelectDayAction>(actionParametersOf(SelectDayAction.dateKey to date.toString()))),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = date.day.toString(),
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(
|
||||||
|
color = ink,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = if (isToday || isSelected) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (hasEvents) "•" else " ",
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(color = if (isSelected) ink else GlanceTheme.colors.primary, fontSize = 7.sp, textAlign = TextAlign.Center),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DayList(day: LocalDate, data: MonthDayData.Ready, preview: Boolean) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val source = data.source
|
||||||
|
val zone = systemZone()
|
||||||
|
val events = groupAgendaDays(day, day, source.instances, zone).firstOrNull()?.events.orEmpty()
|
||||||
|
.let { all -> if (data.options.pastDisplay == PastEventDisplay.HIDE) all.filterNot { it.hasEnded(data.now) } else all }
|
||||||
|
val metrics = compactRowMetrics(data.options.textSize, context.resources.configuration.fontScale)
|
||||||
|
val openDay = actionStartActivity(MainActivity.openDateIntent(context, day, CalendarView.Month))
|
||||||
|
Column(modifier = GlanceModifier.fillMaxSize()) {
|
||||||
|
val date = localizedDateFormatter(widgetLocale(), "EEEdMMM").format(day.toJavaLocalDate())
|
||||||
|
val label = when (day) {
|
||||||
|
source.today, source.today.plus(1, DateTimeUnit.DAY) -> "${relativeDayLabel(context, day, source.today)} · $date"
|
||||||
|
else -> date
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = metrics.time, fontWeight = FontWeight.Medium),
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.padding(bottom = 4.dp)
|
||||||
|
.wallpaperBacking(radius = 8.dp)
|
||||||
|
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
|
.clickable(openDay),
|
||||||
|
)
|
||||||
|
if (events.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = GlanceModifier.fillMaxSize().cornerRadius(14.dp).background(widgetCardColor()).clickable(openDay),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = context.getString(R.string.widget_day_empty),
|
||||||
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.time, textAlign = TextAlign.Center),
|
||||||
|
modifier = GlanceModifier.padding(8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val row: @Composable (Int) -> Unit = { i ->
|
||||||
|
val event = events[i]
|
||||||
|
WidgetEventRow(
|
||||||
|
event = event,
|
||||||
|
secondary = widgetTimeLabel(context, event, day, data.is24Hour),
|
||||||
|
soften = source.soften,
|
||||||
|
metrics = metrics,
|
||||||
|
position = positionOf(i, events.size),
|
||||||
|
source = CalendarView.Month,
|
||||||
|
dimmed = data.options.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (preview) {
|
||||||
|
Column(modifier = GlanceModifier.fillMaxSize()) { repeat(minOf(events.size, 8)) { row(it) } }
|
||||||
|
} else {
|
||||||
|
LazyColumn(modifier = GlanceModifier.fillMaxSize()) { items(events.size) { row(it) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.today
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||||
|
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||||
|
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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.cornerRadius
|
||||||
|
import androidx.glance.appwidget.lazy.LazyColumn
|
||||||
|
import androidx.glance.appwidget.lazy.items
|
||||||
|
import androidx.glance.appwidget.provideContent
|
||||||
|
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
|
||||||
|
import androidx.glance.layout.padding
|
||||||
|
import androidx.glance.layout.width
|
||||||
|
import androidx.glance.semantics.contentDescription
|
||||||
|
import androidx.glance.semantics.semantics
|
||||||
|
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||||
|
import androidx.glance.text.FontWeight
|
||||||
|
import androidx.glance.text.Text
|
||||||
|
import androidx.glance.text.TextAlign
|
||||||
|
import androidx.glance.text.TextStyle
|
||||||
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
|
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.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.EventRowMetrics
|
||||||
|
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.locale.localizedDateFormatter
|
||||||
|
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toJavaDayOfWeek
|
||||||
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Today list (#438): only today's events beside today's date pill. Once
|
||||||
|
* they're over, or with none at all, it says so and shows tomorrow's first
|
||||||
|
* one or two events in a quieter style.
|
||||||
|
*/
|
||||||
|
class TodayWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
|
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||||
|
|
||||||
|
// Exact: whether one or two of tomorrow's events fit follows the height.
|
||||||
|
override val sizeMode = SizeMode.Exact
|
||||||
|
|
||||||
|
override val previewSizeMode = previewSizeOf(WidgetKind.TODAY)
|
||||||
|
|
||||||
|
override suspend fun providePreview(context: Context, widgetCategory: Int) =
|
||||||
|
provideSamplePreview(context, WidgetKind.TODAY)
|
||||||
|
|
||||||
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
|
val widgetData = WidgetData(context.loadTodayList(context.widgetLoadContext(id)), refreshStamp(context, id))
|
||||||
|
provideContent {
|
||||||
|
val loaded = widgetData.current(currentRefreshStamp()) { context.loadTodayList(context.widgetLoadContext(id)) }
|
||||||
|
TodayWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TodayWidgetReceiver : CalendulaWidgetReceiver() {
|
||||||
|
override val glanceAppWidget: GlanceAppWidget = TodayWidget()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal suspend fun Context.loadTodayList(load: WidgetLoadContext) = loadDays(load) { today, _ ->
|
||||||
|
today..today.plus(1, DateTimeUnit.DAY)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val PillWidth = 44.dp
|
||||||
|
private val RowGap = 2.dp
|
||||||
|
|
||||||
|
/** What the list side shows: today's events, or the look ahead once none are left. */
|
||||||
|
internal sealed interface TodayRows {
|
||||||
|
data class Today(val events: List<EventInstance>) : TodayRows
|
||||||
|
data class NothingLeft(val tomorrow: List<EventInstance>) : TodayRows
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun todayRows(data: DaysWidgetData.Ready): TodayRows {
|
||||||
|
val today = data.eventsOn(data.today)
|
||||||
|
if (today.none { !it.hasEnded(data.now) }) {
|
||||||
|
return TodayRows.NothingLeft(data.eventsOn(data.today.plus(1, DateTimeUnit.DAY)))
|
||||||
|
}
|
||||||
|
val shown = if (data.options.pastDisplay == PastEventDisplay.HIDE) today.filterNot { it.hasEnded(data.now) } else today
|
||||||
|
return TodayRows.Today(shown)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The whole widget, shared with its config preview, which draws its list as a plain column. */
|
||||||
|
@Composable
|
||||||
|
internal fun TodayWidgetContent(
|
||||||
|
data: DaysWidgetData,
|
||||||
|
appearance: WidgetAppearance,
|
||||||
|
appTheme: ThemeMode,
|
||||||
|
preview: Boolean = false,
|
||||||
|
) {
|
||||||
|
CalendulaWidgetRoot(appearance, appTheme) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
when (data) {
|
||||||
|
DaysWidgetData.NeedsPermission -> WidgetMessage(context.getString(R.string.widget_needs_permission))
|
||||||
|
is DaysWidgetData.Ready -> Row(modifier = GlanceModifier.fillMaxSize().padding(6.dp)) {
|
||||||
|
TodayPill(data.today)
|
||||||
|
Spacer(GlanceModifier.width(6.dp))
|
||||||
|
val metrics = compactRowMetrics(data.options.textSize, context.resources.configuration.fontScale)
|
||||||
|
Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight()) {
|
||||||
|
when (val rows = todayRows(data)) {
|
||||||
|
is TodayRows.Today -> TodayList(rows.events, data, metrics, preview)
|
||||||
|
is TodayRows.NothingLeft -> NothingLeft(rows.tomorrow, data, metrics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TodayList(events: List<EventInstance>, data: DaysWidgetData.Ready, metrics: EventRowMetrics, preview: Boolean) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val row: @Composable (Int) -> Unit = { index ->
|
||||||
|
val event = events[index]
|
||||||
|
WidgetEventRow(
|
||||||
|
event = event,
|
||||||
|
secondary = widgetTimeLabel(context, event, data.today, data.is24Hour),
|
||||||
|
soften = data.soften,
|
||||||
|
metrics = metrics,
|
||||||
|
position = positionOf(index, events.size),
|
||||||
|
source = CalendarView.Day,
|
||||||
|
dimmed = data.options.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (preview) {
|
||||||
|
val fits = ((LocalSize.current.height - 12.dp + RowGap) / (metrics.rowHeight + RowGap)).toInt().coerceAtLeast(1)
|
||||||
|
Column(modifier = GlanceModifier.fillMaxSize()) { repeat(minOf(events.size, fits)) { row(it) } }
|
||||||
|
} else {
|
||||||
|
LazyColumn(modifier = GlanceModifier.fillMaxSize()) { items(events.size) { row(it) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Nothing left today", then as many of tomorrow's first two events as the height holds. */
|
||||||
|
@Composable
|
||||||
|
private fun NothingLeft(tomorrow: List<EventInstance>, data: DaysWidgetData.Ready, metrics: EventRowMetrics) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val fontScale = context.resources.configuration.fontScale
|
||||||
|
val lineHeight = 20.dp * fontScale
|
||||||
|
val room = LocalSize.current.height - 12.dp - lineHeight
|
||||||
|
val fits = ((room + RowGap) / (metrics.rowHeight + RowGap)).toInt().coerceIn(0, 2)
|
||||||
|
val ahead = tomorrow.take(fits)
|
||||||
|
val backing = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
|
||||||
|
GlanceModifier.wallpaperBacking(radius = 8.dp).padding(horizontal = 8.dp)
|
||||||
|
} else {
|
||||||
|
GlanceModifier.padding(horizontal = 4.dp)
|
||||||
|
}
|
||||||
|
Column(modifier = GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = context.getString(R.string.widget_nothing_left_today),
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.title, fontWeight = FontWeight.Medium),
|
||||||
|
modifier = backing.height(lineHeight),
|
||||||
|
)
|
||||||
|
val tomorrowDate = data.today.plus(1, DateTimeUnit.DAY)
|
||||||
|
val tomorrowLabel = relativeDayLabel(context, tomorrowDate, data.today)
|
||||||
|
ahead.forEachIndexed { index, event ->
|
||||||
|
WidgetEventRow(
|
||||||
|
event = event,
|
||||||
|
secondary = "$tomorrowLabel · ${widgetTimeLabel(context, event, tomorrowDate, data.is24Hour)}",
|
||||||
|
soften = data.soften,
|
||||||
|
metrics = metrics,
|
||||||
|
position = positionOf(index, ahead.size),
|
||||||
|
source = CalendarView.Day,
|
||||||
|
quiet = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Today's weekday and number, filled like the agenda's today pill; opens today. */
|
||||||
|
@Composable
|
||||||
|
private fun TodayPill(today: LocalDate) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val locale = widgetLocale()
|
||||||
|
Column(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.width(PillWidth)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.cornerRadius(PillWidth / 2)
|
||||||
|
.background(GlanceTheme.colors.primary)
|
||||||
|
.clickable(actionStartActivity(MainActivity.openDateIntent(context, today, CalendarView.Day)))
|
||||||
|
.semantics { contentDescription = localizedDateFormatter(locale, "EEEEdMMMM").format(today.toJavaLocalDate()) },
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = today.dayOfWeek.toJavaDayOfWeek().getDisplayName(JavaTextStyle.SHORT, locale),
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(color = GlanceTheme.colors.onPrimary, fontSize = 11.sp, textAlign = TextAlign.Center),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = today.day.toString(),
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(
|
||||||
|
color = GlanceTheme.colors.onPrimary,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.upcoming
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||||
|
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||||
|
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||||
|
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 val previewSizeMode = previewSizeOf(WidgetKind.UPCOMING)
|
||||||
|
|
||||||
|
override suspend fun providePreview(context: Context, widgetCategory: Int) =
|
||||||
|
provideSamplePreview(context, WidgetKind.UPCOMING)
|
||||||
|
|
||||||
|
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,196 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.week
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||||
|
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||||
|
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
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.action.clickable
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
import androidx.glance.appwidget.SizeMode
|
||||||
|
import androidx.glance.appwidget.action.actionStartActivity
|
||||||
|
import androidx.glance.appwidget.cornerRadius
|
||||||
|
import androidx.glance.appwidget.provideContent
|
||||||
|
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.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.state.PreferencesGlanceStateDefinition
|
||||||
|
import androidx.glance.text.FontWeight
|
||||||
|
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.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
|
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.loadDays
|
||||||
|
import de.jeanlucmakiola.calendula.widget.weekOf
|
||||||
|
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||||
|
import de.jeanlucmakiola.calendula.widget.widgetLocale
|
||||||
|
import de.jeanlucmakiola.calendula.widget.widgetCardColor
|
||||||
|
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
|
||||||
|
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.themedColor
|
||||||
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
|
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toJavaDayOfWeek
|
||||||
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 4×1 week strip (#436): the current week's seven days from the app's week
|
||||||
|
* start, today filled, a dot per event colour under each day. A day opens
|
||||||
|
* its day view.
|
||||||
|
*/
|
||||||
|
class WeekStripWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
|
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||||
|
|
||||||
|
override val sizeMode = SizeMode.Single
|
||||||
|
|
||||||
|
override val previewSizeMode = previewSizeOf(WidgetKind.WEEK_STRIP)
|
||||||
|
|
||||||
|
override suspend fun providePreview(context: Context, widgetCategory: Int) =
|
||||||
|
provideSamplePreview(context, WidgetKind.WEEK_STRIP)
|
||||||
|
|
||||||
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
|
val widgetData = WidgetData(context.loadWeek(id), refreshStamp(context, id))
|
||||||
|
provideContent {
|
||||||
|
val loaded = widgetData.current(currentRefreshStamp()) { context.loadWeek(id) }
|
||||||
|
WeekStripWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class WeekStripWidgetReceiver : CalendulaWidgetReceiver() {
|
||||||
|
override val glanceAppWidget: GlanceAppWidget = WeekStripWidget()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun Context.loadWeek(id: GlanceId) = loadDays(widgetLoadContext(id), ::weekOf)
|
||||||
|
|
||||||
|
/** Most dots a day shows; more colours than this read as noise at this size. */
|
||||||
|
private const val MAX_DOTS = 3
|
||||||
|
|
||||||
|
private val DayGap = 4.dp
|
||||||
|
private val DotSize = 5.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun WeekStripWidgetContent(data: DaysWidgetData, appearance: WidgetAppearance, appTheme: ThemeMode) {
|
||||||
|
CalendulaWidgetRoot(appearance, appTheme) {
|
||||||
|
when (data) {
|
||||||
|
DaysWidgetData.NeedsPermission ->
|
||||||
|
WidgetMessage(LocalContext.current.getString(R.string.widget_needs_permission))
|
||||||
|
is DaysWidgetData.Ready -> Row(
|
||||||
|
modifier = GlanceModifier.fillMaxSize().padding(6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
for (i in 0 until 7) {
|
||||||
|
if (i > 0) Spacer(GlanceModifier.width(DayGap))
|
||||||
|
Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight()) {
|
||||||
|
DayTile(data.firstDay.plus(i, DateTimeUnit.DAY), data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DayTile(date: LocalDate, data: DaysWidgetData.Ready) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val isToday = date == data.today
|
||||||
|
val container = if (isToday) GlanceTheme.colors.primary else widgetCardColor()
|
||||||
|
val ink = when {
|
||||||
|
isToday -> GlanceTheme.colors.onPrimary
|
||||||
|
date < data.today -> GlanceTheme.colors.onSurfaceVariant
|
||||||
|
else -> GlanceTheme.colors.onSurface
|
||||||
|
}
|
||||||
|
val locale = widgetLocale()
|
||||||
|
val colors = data.eventsOn(date).map { it.color }.distinct()
|
||||||
|
val label = localizedDateFormatter(locale, "EEEEdMMMM").format(date.toJavaLocalDate())
|
||||||
|
Column(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.cornerRadius(14.dp)
|
||||||
|
.background(container)
|
||||||
|
.clickable(actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Week)))
|
||||||
|
.semantics {
|
||||||
|
contentDescription = if (colors.isEmpty()) label else context.resources.getQuantityString(
|
||||||
|
R.plurals.widget_day_with_events, data.eventsOn(date).size, label, data.eventsOn(date).size,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = date.dayOfWeek.toJavaDayOfWeek().getDisplayName(JavaTextStyle.SHORT, locale),
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(color = ink, fontSize = 11.sp, textAlign = TextAlign.Center),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = date.day.toString(),
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(color = ink, fontSize = 17.sp, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center),
|
||||||
|
)
|
||||||
|
Spacer(GlanceModifier.height(3.dp))
|
||||||
|
// A dot row is always drawn, so the numbers line up across days.
|
||||||
|
Row(modifier = GlanceModifier.height(DotSize)) {
|
||||||
|
colors.take(MAX_DOTS).forEachIndexed { index, color ->
|
||||||
|
if (index > 0) Spacer(GlanceModifier.width(3.dp))
|
||||||
|
Dot(dotColor(color, isToday, data.soften))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A mark on the tile: the accent tone on a card, the lighter fill tone on today's primary. */
|
||||||
|
@Composable
|
||||||
|
private fun dotColor(color: Int, onPrimary: Boolean, soften: Boolean): ColorProvider = if (onPrimary) {
|
||||||
|
themedColor(day = eventFill(color, dark = false, soften), night = eventFill(color, dark = true, soften))
|
||||||
|
} else {
|
||||||
|
themedColor(day = eventAccent(color, dark = false, soften), night = eventAccent(color, dark = true, soften))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Dot(color: ColorProvider) {
|
||||||
|
Image(
|
||||||
|
provider = ImageProvider(R.drawable.widget_dot),
|
||||||
|
contentDescription = null,
|
||||||
|
colorFilter = ColorFilter.tint(color),
|
||||||
|
modifier = GlanceModifier.size(DotSize),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:shape="rectangle">
|
android:shape="rectangle">
|
||||||
<solid android:color="#4F7CAC" />
|
<solid android:color="#FFFFFF" />
|
||||||
<corners android:radius="3dp" />
|
<corners android:radius="3dp" />
|
||||||
</shape>
|
</shape>
|
||||||
|
|||||||
@@ -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_cell" />
|
||||||
|
<corners android:radius="12dp" />
|
||||||
|
</shape>
|
||||||
@@ -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="#FFFFFF" />
|
||||||
|
<corners android:radius="14dp" />
|
||||||
|
</shape>
|
||||||
@@ -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_outside" />
|
||||||
|
<corners android:radius="12dp" />
|
||||||
|
</shape>
|
||||||
@@ -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_primary" />
|
||||||
|
<corners android:radius="14dp" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="oval">
|
||||||
|
<solid android:color="#FFFFFFFF" />
|
||||||
|
</shape>
|
||||||
@@ -1,124 +1,186 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Static mock shown in the widget picker (android:previewLayout). Not the
|
<!-- Static mock shown in the widget picker on Android 14 and below
|
||||||
runtime layout — the live widget is rendered by Glance. Colours are the
|
(android:previewLayout); 15+ shows the generated preview instead. -->
|
||||||
brand light scheme so the picker preview reads on its light sheet. -->
|
|
||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical"
|
|
||||||
android:background="@drawable/preview_widget_bg"
|
android:background="@drawable/preview_widget_bg"
|
||||||
android:padding="14dp">
|
android:orientation="vertical"
|
||||||
|
android:padding="8dp"
|
||||||
<TextView
|
tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessParent">
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/widget_agenda_title"
|
|
||||||
android:textColor="@color/widget_preview_primary"
|
|
||||||
android:textSize="15sp"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="8dp"
|
|
||||||
android:text="@string/agenda_header_today"
|
|
||||||
android:textColor="@color/widget_preview_primary"
|
|
||||||
android:textSize="12sp"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="10dp"
|
android:gravity="center_vertical"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal">
|
||||||
android:gravity="center_vertical">
|
<TextView
|
||||||
<ImageView
|
android:layout_width="0dp"
|
||||||
android:layout_width="5dp"
|
android:layout_height="wrap_content"
|
||||||
android:layout_height="34dp"
|
android:layout_weight="1"
|
||||||
android:contentDescription="@null"
|
android:maxLines="1"
|
||||||
android:background="@drawable/preview_stripe"
|
android:paddingHorizontal="4dp"
|
||||||
android:backgroundTint="#4F7CAC" />
|
android:text="@string/widget_agenda_title"
|
||||||
|
android:textColor="@color/widget_preview_primary"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<TextView style="@style/WidgetPreviewAddButton" android:text="+" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@drawable/preview_widget_today_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:paddingTop="6dp"
|
||||||
|
android:text="Mon 26"
|
||||||
|
android:textColor="@color/widget_preview_on_primary"
|
||||||
|
android:textSize="13sp"
|
||||||
|
tools:ignore="HardcodedText" />
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="6dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="10dp"
|
android:layout_marginTop="0dp"
|
||||||
android:orientation="vertical">
|
android:background="@drawable/preview_widget_event_bg"
|
||||||
|
android:backgroundTint="@color/widget_preview_event_blue"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingHorizontal="10dp"
|
||||||
|
android:paddingVertical="3dp">
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Standup"
|
android:ellipsize="end"
|
||||||
android:textColor="@color/widget_preview_on_surface"
|
android:maxLines="1"
|
||||||
android:textSize="14sp" />
|
android:text="@string/settings_past_events_sample_morning"
|
||||||
|
android:textColor="@color/widget_preview_on_event_light"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="09:00 – 09:30"
|
android:ellipsize="end"
|
||||||
android:textColor="@color/widget_preview_variant"
|
android:maxLines="1"
|
||||||
android:textSize="12sp" />
|
android:text="9:30 – 10:00"
|
||||||
|
android:textColor="@color/widget_preview_on_event_light"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="3dp"
|
||||||
|
android:background="@drawable/preview_widget_event_bg"
|
||||||
|
android:backgroundTint="@color/widget_preview_event_green"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingHorizontal="10dp"
|
||||||
|
android:paddingVertical="3dp">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="@string/settings_past_events_sample_midday"
|
||||||
|
android:textColor="@color/widget_preview_on_event_light"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="12:30 – 13:30"
|
||||||
|
android:textColor="@color/widget_preview_on_event_light"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="3dp"
|
||||||
|
android:background="@drawable/preview_widget_event_bg"
|
||||||
|
android:backgroundTint="@color/widget_preview_event_purple"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingHorizontal="10dp"
|
||||||
|
android:paddingVertical="3dp">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="@string/settings_past_events_sample_evening"
|
||||||
|
android:textColor="@color/widget_preview_on_event_dark"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="19:00 – 21:00"
|
||||||
|
android:textColor="@color/widget_preview_on_event_dark"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="8dp"
|
android:layout_marginTop="6dp"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal">
|
||||||
android:gravity="center_vertical">
|
<TextView
|
||||||
<ImageView
|
android:layout_width="40dp"
|
||||||
android:layout_width="5dp"
|
android:layout_height="match_parent"
|
||||||
android:layout_height="34dp"
|
android:background="@drawable/preview_widget_today_bg"
|
||||||
android:contentDescription="@null"
|
android:gravity="center_horizontal"
|
||||||
android:background="@drawable/preview_stripe"
|
android:paddingTop="6dp"
|
||||||
android:backgroundTint="#6FA37A" />
|
android:text="Tue 27"
|
||||||
|
android:textColor="@color/widget_preview_on_primary"
|
||||||
|
android:textSize="13sp"
|
||||||
|
tools:ignore="HardcodedText" />
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="6dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="10dp"
|
android:layout_marginTop="0dp"
|
||||||
android:orientation="vertical">
|
android:background="@drawable/preview_widget_event_bg"
|
||||||
|
android:backgroundTint="@color/widget_preview_event_orange"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingHorizontal="10dp"
|
||||||
|
android:paddingVertical="3dp">
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Design review"
|
android:ellipsize="end"
|
||||||
android:textColor="@color/widget_preview_on_surface"
|
android:maxLines="1"
|
||||||
android:textSize="14sp" />
|
android:text="@string/widget_preview_sample_trip"
|
||||||
|
android:textColor="@color/widget_preview_on_event_light"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="11:30 – 12:30"
|
android:ellipsize="end"
|
||||||
android:textColor="@color/widget_preview_variant"
|
android:maxLines="1"
|
||||||
android:textSize="12sp" />
|
android:text="@string/event_detail_all_day"
|
||||||
|
android:textColor="@color/widget_preview_on_event_light"
|
||||||
|
android:textSize="11sp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="8dp"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:gravity="center_vertical">
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="5dp"
|
|
||||||
android:layout_height="34dp"
|
|
||||||
android:contentDescription="@null"
|
|
||||||
android:background="@drawable/preview_stripe"
|
|
||||||
android:backgroundTint="#C58A56" />
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="10dp"
|
|
||||||
android:orientation="vertical">
|
|
||||||
<TextView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="1:1 with Mara"
|
|
||||||
android:textColor="@color/widget_preview_on_surface"
|
|
||||||
android:textSize="14sp" />
|
|
||||||
<TextView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="14:00 – 14:30"
|
|
||||||
android:textColor="@color/widget_preview_variant"
|
|
||||||
android:textSize="12sp" />
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Static mock shown in the widget picker on Android 14 and below
|
||||||
|
(android:previewLayout); 15+ shows the generated preview instead. -->
|
||||||
|
<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:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="12dp"
|
||||||
|
tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessParent">
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="4dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@drawable/preview_stripe"
|
||||||
|
android:backgroundTint="@color/widget_preview_event_blue" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="10dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="@string/settings_past_events_sample_morning"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="13sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="in 2 h"
|
||||||
|
android:textColor="@color/widget_preview_primary"
|
||||||
|
android:textSize="22sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="9:30"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Static mock shown in the widget picker on Android 14 and below
|
||||||
|
(android:previewLayout); 15+ shows the generated preview instead. -->
|
||||||
|
<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:gravity="center"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="4dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="MON"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
tools:ignore="HardcodedText" />
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="48dp"
|
||||||
|
android:layout_height="48dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/floret_widget_shape_cookie"
|
||||||
|
android:tint="@color/widget_preview_primary"
|
||||||
|
tools:ignore="UseAppTint" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:text="26"
|
||||||
|
android:textColor="@color/widget_preview_on_primary"
|
||||||
|
android:textSize="20sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
tools:ignore="HardcodedText" />
|
||||||
|
</FrameLayout>
|
||||||
|
</LinearLayout>
|
||||||
@@ -1,114 +1,776 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Static mock shown in the widget picker (android:previewLayout). The live
|
<!-- Static mock shown in the widget picker on Android 14 and below
|
||||||
widget is rendered by Glance with real events; this only needs to read as
|
(android:previewLayout); 15+ shows the generated preview instead. -->
|
||||||
"a month grid" in the picker. -->
|
|
||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical"
|
|
||||||
android:background="@drawable/preview_widget_bg"
|
android:background="@drawable/preview_widget_bg"
|
||||||
android:padding="12dp">
|
android:orientation="vertical"
|
||||||
|
android:padding="8dp"
|
||||||
|
tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessParent">
|
||||||
|
|
||||||
<!-- Header -->
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="horizontal"
|
android:gravity="center_vertical"
|
||||||
android:gravity="center_vertical">
|
android:orientation="horizontal">
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="‹"
|
|
||||||
android:textColor="@color/widget_preview_variant"
|
|
||||||
android:textSize="16sp" />
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:gravity="center"
|
android:maxLines="1"
|
||||||
android:text="June 2026"
|
android:paddingHorizontal="4dp"
|
||||||
|
android:text="October"
|
||||||
android:textColor="@color/widget_preview_primary"
|
android:textColor="@color/widget_preview_primary"
|
||||||
android:textSize="15sp"
|
android:textSize="15sp"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
<TextView
|
<TextView style="@style/WidgetPreviewHeaderButton" android:text="‹" />
|
||||||
android:layout_width="wrap_content"
|
<TextView style="@style/WidgetPreviewHeaderButton" android:layout_width="28dp" android:text="26" android:textStyle="bold" />
|
||||||
android:layout_height="wrap_content"
|
<TextView style="@style/WidgetPreviewHeaderButton" android:text="›" />
|
||||||
android:text="›"
|
<TextView style="@style/WidgetPreviewAddButton" android:text="+" />
|
||||||
android:textColor="@color/widget_preview_variant"
|
|
||||||
android:textSize="16sp" />
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- Weekday header -->
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="6dp"
|
android:layout_marginTop="6dp"
|
||||||
|
android:layout_marginBottom="1dp"
|
||||||
android:orientation="horizontal">
|
android:orientation="horizontal">
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="M" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
|
<TextView
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="T" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
|
android:layout_width="0dp"
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="W" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
|
android:layout_height="wrap_content"
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="T" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
|
android:layout_marginHorizontal="1dp"
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="F" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
|
android:layout_weight="1"
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="S" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="S" android:textColor="@color/widget_preview_variant" android:textSize="11sp" />
|
android:gravity="center"
|
||||||
|
android:text="M"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="T"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="W"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="T"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="F"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="S"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="S"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- Week 1: 1..7 (event bar on the 3rd) -->
|
<LinearLayout
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal" android:layout_marginTop="4dp">
|
android:layout_width="match_parent"
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="1" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
android:layout_height="0dp"
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="2" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
android:layout_weight="1"
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="3" android:textColor="#4F7CAC" android:textStyle="bold" android:textSize="12sp" />
|
android:orientation="horizontal">
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="4" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
<LinearLayout
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="5" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
android:layout_width="0dp"
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="6" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
android:layout_height="match_parent"
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="7" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
android:layout_margin="1dp"
|
||||||
</LinearLayout>
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_outside_bg"
|
||||||
<!-- Week 2: 8..14 (bars on 11,12,13) -->
|
android:gravity="center_horizontal"
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal" android:layout_marginTop="4dp">
|
android:orientation="vertical">
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="8" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
<TextView
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="9" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
android:layout_width="wrap_content"
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="10" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
android:layout_height="18dp"
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="11" android:textColor="#6FA37A" android:textStyle="bold" android:textSize="12sp" />
|
android:gravity="center"
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="12" android:textColor="#6FA37A" android:textStyle="bold" android:textSize="12sp" />
|
android:text="28"
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="13" android:textColor="#6FA37A" android:textStyle="bold" android:textSize="12sp" />
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="14" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
android:textSize="10sp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
<!-- Week 3: 15..21 (17 = today) -->
|
android:layout_width="0dp"
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal" android:layout_marginTop="4dp">
|
android:layout_height="match_parent"
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="15" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
android:layout_margin="1dp"
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="16" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
android:layout_weight="1"
|
||||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:orientation="horizontal">
|
android:background="@drawable/preview_widget_outside_bg"
|
||||||
<TextView android:layout_width="22dp" android:layout_height="22dp" android:gravity="center"
|
android:gravity="center_horizontal"
|
||||||
android:text="17" android:textColor="@color/widget_preview_on_primary" android:textStyle="bold" android:textSize="12sp"
|
android:orientation="vertical">
|
||||||
android:background="@drawable/preview_today_circle" />
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="29"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_margin="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_outside_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="30"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_margin="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="1"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_margin="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="2"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_margin="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="3"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_margin="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="4"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="18" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="19" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="20" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="21" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
<!-- Week 4: 22..28 (bar on 24) -->
|
android:layout_width="match_parent"
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal" android:layout_marginTop="4dp">
|
android:layout_height="0dp"
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="22" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
android:layout_weight="1"
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="23" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
android:orientation="horizontal">
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="24" android:textColor="#C58A56" android:textStyle="bold" android:textSize="12sp" />
|
<LinearLayout
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="25" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
android:layout_width="0dp"
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="26" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
android:layout_height="match_parent"
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="27" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
android:layout_margin="1dp"
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="28" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="5"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_margin="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="6"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_margin="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="7"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_margin="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="8"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_margin="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="9"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_margin="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="10"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_margin="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="11"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
<!-- Week 5: 29, 30, then trailing July days greyed -->
|
android:layout_width="match_parent"
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal" android:layout_marginTop="4dp">
|
android:layout_height="0dp"
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="29" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
android:layout_weight="1"
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="30" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
android:orientation="horizontal">
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="1" android:textColor="@color/widget_preview_variant" android:textSize="12sp" />
|
<LinearLayout
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="2" android:textColor="@color/widget_preview_variant" android:textSize="12sp" />
|
android:layout_width="0dp"
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="3" android:textColor="@color/widget_preview_variant" android:textSize="12sp" />
|
android:layout_height="match_parent"
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="4" android:textColor="@color/widget_preview_variant" android:textSize="12sp" />
|
android:layout_margin="1dp"
|
||||||
<TextView android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="center" android:text="5" android:textColor="@color/widget_preview_variant" android:textSize="12sp" />
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="12"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_margin="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="13"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_margin="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="14"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_margin="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="15"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_margin="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="16"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_margin="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="17"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_margin="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="18"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_margin="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="19"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_margin="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="20"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="2dp"
|
||||||
|
android:background="@drawable/preview_stripe"
|
||||||
|
android:backgroundTint="@color/widget_preview_event_blue"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:paddingHorizontal="2dp"
|
||||||
|
android:text="@string/settings_past_events_sample_morning"
|
||||||
|
android:textColor="@color/widget_preview_on_event_light"
|
||||||
|
android:textSize="8sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_margin="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="21"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_margin="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="22"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_margin="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="23"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_margin="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="24"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="2dp"
|
||||||
|
android:background="@drawable/preview_stripe"
|
||||||
|
android:backgroundTint="@color/widget_preview_event_purple"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:paddingHorizontal="2dp"
|
||||||
|
android:text="@string/settings_past_events_sample_evening"
|
||||||
|
android:textColor="@color/widget_preview_on_event_dark"
|
||||||
|
android:textSize="8sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_margin="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="25"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_margin="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="18dp"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:background="@drawable/preview_today_circle"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="26"
|
||||||
|
android:textColor="@color/widget_preview_on_primary"
|
||||||
|
android:textSize="10sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="2dp"
|
||||||
|
android:background="@drawable/preview_stripe"
|
||||||
|
android:backgroundTint="@color/widget_preview_event_blue"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:paddingHorizontal="2dp"
|
||||||
|
android:text="@string/settings_past_events_sample_morning"
|
||||||
|
android:textColor="@color/widget_preview_on_event_light"
|
||||||
|
android:textSize="8sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_margin="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="27"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="2dp"
|
||||||
|
android:background="@drawable/preview_stripe"
|
||||||
|
android:backgroundTint="@color/widget_preview_event_orange"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:paddingHorizontal="2dp"
|
||||||
|
android:text="@string/widget_preview_sample_trip"
|
||||||
|
android:textColor="@color/widget_preview_on_event_light"
|
||||||
|
android:textSize="8sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_margin="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="28"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="2dp"
|
||||||
|
android:background="@drawable/preview_stripe"
|
||||||
|
android:backgroundTint="@color/widget_preview_event_red"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:paddingHorizontal="2dp"
|
||||||
|
android:text="@string/widget_preview_sample_dentist"
|
||||||
|
android:textColor="@color/widget_preview_on_event_dark"
|
||||||
|
android:textSize="8sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_margin="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="29"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_margin="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="30"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_margin="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_cell_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="31"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="2dp"
|
||||||
|
android:background="@drawable/preview_stripe"
|
||||||
|
android:backgroundTint="@color/widget_preview_event_purple"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:paddingHorizontal="2dp"
|
||||||
|
android:text="@string/settings_past_events_sample_evening"
|
||||||
|
android:textColor="@color/widget_preview_on_event_dark"
|
||||||
|
android:textSize="8sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_margin="1dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_outside_bg"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="1"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -0,0 +1,518 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Static mock shown in the widget picker on Android 14 and below
|
||||||
|
(android:previewLayout); 15+ shows the generated preview instead. -->
|
||||||
|
<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="8dp"
|
||||||
|
tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessParent">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:paddingHorizontal="4dp"
|
||||||
|
android:text="October"
|
||||||
|
android:textColor="@color/widget_preview_primary"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<TextView style="@style/WidgetPreviewHeaderButton" android:text="‹" />
|
||||||
|
<TextView style="@style/WidgetPreviewHeaderButton" android:layout_width="28dp" android:text="26" android:textStyle="bold" />
|
||||||
|
<TextView style="@style/WidgetPreviewHeaderButton" android:text="›" />
|
||||||
|
<TextView style="@style/WidgetPreviewAddButton" android:text="+" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1.1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="M"
|
||||||
|
android:textColor="@color/widget_preview_variant"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="T"
|
||||||
|
android:textColor="@color/widget_preview_variant"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="W"
|
||||||
|
android:textColor="@color/widget_preview_variant"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="T"
|
||||||
|
android:textColor="@color/widget_preview_variant"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="F"
|
||||||
|
android:textColor="@color/widget_preview_variant"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="S"
|
||||||
|
android:textColor="@color/widget_preview_variant"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="S"
|
||||||
|
android:textColor="@color/widget_preview_variant"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="28"
|
||||||
|
android:textColor="@color/widget_preview_variant"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="29"
|
||||||
|
android:textColor="@color/widget_preview_variant"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="30"
|
||||||
|
android:textColor="@color/widget_preview_variant"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="1"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="2"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="3"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="4"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="5"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="6"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="7"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="8"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="9"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="10"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="11"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="12"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="13"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="14"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="15"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="16"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="17"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="18"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="19"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="20"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="21"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="22"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="23"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="24"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="25"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/preview_widget_today_bg"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="26"
|
||||||
|
android:textColor="@color/widget_preview_on_primary"
|
||||||
|
android:textSize="10sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="27"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="28"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="29"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="30"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="31"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="1"
|
||||||
|
android:textColor="@color/widget_preview_variant"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingHorizontal="4dp"
|
||||||
|
android:text="@string/widget_today"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="3dp"
|
||||||
|
android:background="@drawable/preview_widget_event_bg"
|
||||||
|
android:backgroundTint="@color/widget_preview_event_blue"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingHorizontal="10dp"
|
||||||
|
android:paddingVertical="3dp">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="@string/settings_past_events_sample_morning"
|
||||||
|
android:textColor="@color/widget_preview_on_event_light"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="9:30 – 10:00"
|
||||||
|
android:textColor="@color/widget_preview_on_event_light"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="3dp"
|
||||||
|
android:background="@drawable/preview_widget_event_bg"
|
||||||
|
android:backgroundTint="@color/widget_preview_event_green"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingHorizontal="10dp"
|
||||||
|
android:paddingVertical="3dp">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="@string/settings_past_events_sample_midday"
|
||||||
|
android:textColor="@color/widget_preview_on_event_light"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="12:30 – 13:30"
|
||||||
|
android:textColor="@color/widget_preview_on_event_light"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="3dp"
|
||||||
|
android:background="@drawable/preview_widget_event_bg"
|
||||||
|
android:backgroundTint="@color/widget_preview_event_purple"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingHorizontal="10dp"
|
||||||
|
android:paddingVertical="3dp">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="@string/settings_past_events_sample_evening"
|
||||||
|
android:textColor="@color/widget_preview_on_event_dark"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="19:00 – 21:00"
|
||||||
|
android:textColor="@color/widget_preview_on_event_dark"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Static mock shown in the widget picker on Android 14 and below
|
||||||
|
(android:previewLayout); 15+ shows the generated preview instead. -->
|
||||||
|
<FrameLayout 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:padding="6dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/floret_widget_shape_sunny"
|
||||||
|
android:tint="@color/widget_preview_primary"
|
||||||
|
tools:ignore="UseAppTint" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/ic_widget_add"
|
||||||
|
android:tint="@color/widget_preview_on_primary"
|
||||||
|
tools:ignore="UseAppTint" />
|
||||||
|
</FrameLayout>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Static mock shown in the widget picker on Android 14 and below
|
||||||
|
(android:previewLayout); 15+ shows the generated preview instead. -->
|
||||||
|
<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="horizontal"
|
||||||
|
android:padding="6dp"
|
||||||
|
tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessParent">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="44dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@drawable/preview_widget_today_bg"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="Mon 26"
|
||||||
|
android:textColor="@color/widget_preview_on_primary"
|
||||||
|
android:textSize="13sp" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_marginStart="6dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="0dp"
|
||||||
|
android:background="@drawable/preview_widget_event_bg"
|
||||||
|
android:backgroundTint="@color/widget_preview_event_blue"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingHorizontal="10dp"
|
||||||
|
android:paddingVertical="3dp">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="@string/settings_past_events_sample_morning"
|
||||||
|
android:textColor="@color/widget_preview_on_event_light"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="9:30 – 10:00"
|
||||||
|
android:textColor="@color/widget_preview_on_event_light"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="3dp"
|
||||||
|
android:background="@drawable/preview_widget_event_bg"
|
||||||
|
android:backgroundTint="@color/widget_preview_event_green"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingHorizontal="10dp"
|
||||||
|
android:paddingVertical="3dp">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="@string/settings_past_events_sample_midday"
|
||||||
|
android:textColor="@color/widget_preview_on_event_light"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="12:30 – 13:30"
|
||||||
|
android:textColor="@color/widget_preview_on_event_light"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Static mock shown in the widget picker on Android 14 and below
|
||||||
|
(android:previewLayout); 15+ shows the generated preview instead. -->
|
||||||
|
<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="8dp"
|
||||||
|
tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessParent">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="4dp">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="26"
|
||||||
|
android:textColor="@color/widget_preview_primary"
|
||||||
|
android:textSize="28sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:text="Monday October"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:background="@drawable/preview_widget_event_bg"
|
||||||
|
android:backgroundTint="@color/widget_preview_event_blue"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingHorizontal="10dp"
|
||||||
|
android:paddingVertical="3dp">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="@string/settings_past_events_sample_morning"
|
||||||
|
android:textColor="@color/widget_preview_on_event_light"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="9:30 – 10:00"
|
||||||
|
android:textColor="@color/widget_preview_on_event_light"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="3dp"
|
||||||
|
android:background="@drawable/preview_widget_event_bg"
|
||||||
|
android:backgroundTint="@color/widget_preview_event_green"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingHorizontal="10dp"
|
||||||
|
android:paddingVertical="3dp">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="@string/settings_past_events_sample_midday"
|
||||||
|
android:textColor="@color/widget_preview_on_event_light"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:text="12:30 – 13:30"
|
||||||
|
android:textColor="@color/widget_preview_on_event_light"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Static mock shown in the widget picker on Android 14 and below
|
||||||
|
(android:previewLayout); 15+ shows the generated preview instead. -->
|
||||||
|
<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="horizontal"
|
||||||
|
android:padding="6dp"
|
||||||
|
tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessParent">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
style="@style/WidgetPreviewWeekDay"
|
||||||
|
android:background="@drawable/preview_widget_today_bg"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Mon"
|
||||||
|
android:textColor="@color/widget_preview_on_primary"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="26"
|
||||||
|
android:textColor="@color/widget_preview_on_primary"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="6dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
<View
|
||||||
|
android:layout_width="4dp"
|
||||||
|
android:layout_height="4dp"
|
||||||
|
android:layout_marginHorizontal="1dp"
|
||||||
|
android:background="@drawable/preview_today_circle"
|
||||||
|
android:backgroundTint="@color/widget_preview_event_blue" />
|
||||||
|
<View
|
||||||
|
android:layout_width="4dp"
|
||||||
|
android:layout_height="4dp"
|
||||||
|
android:layout_marginHorizontal="1dp"
|
||||||
|
android:background="@drawable/preview_today_circle"
|
||||||
|
android:backgroundTint="@color/widget_preview_event_green" />
|
||||||
|
<View
|
||||||
|
android:layout_width="4dp"
|
||||||
|
android:layout_height="4dp"
|
||||||
|
android:layout_marginHorizontal="1dp"
|
||||||
|
android:background="@drawable/preview_today_circle"
|
||||||
|
android:backgroundTint="@color/widget_preview_event_purple" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
style="@style/WidgetPreviewWeekDay"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Tue"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="27"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="6dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
<View
|
||||||
|
android:layout_width="4dp"
|
||||||
|
android:layout_height="4dp"
|
||||||
|
android:layout_marginHorizontal="1dp"
|
||||||
|
android:background="@drawable/preview_today_circle"
|
||||||
|
android:backgroundTint="@color/widget_preview_event_orange" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
style="@style/WidgetPreviewWeekDay"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Wed"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="28"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="6dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
<View
|
||||||
|
android:layout_width="4dp"
|
||||||
|
android:layout_height="4dp"
|
||||||
|
android:layout_marginHorizontal="1dp"
|
||||||
|
android:background="@drawable/preview_today_circle"
|
||||||
|
android:backgroundTint="@color/widget_preview_event_red" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
style="@style/WidgetPreviewWeekDay"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Thu"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="29"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<Space
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="6dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
style="@style/WidgetPreviewWeekDay"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Fri"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="30"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<Space
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="6dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
style="@style/WidgetPreviewWeekDay"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Sat"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="31"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="6dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
<View
|
||||||
|
android:layout_width="4dp"
|
||||||
|
android:layout_height="4dp"
|
||||||
|
android:layout_marginHorizontal="1dp"
|
||||||
|
android:background="@drawable/preview_today_circle"
|
||||||
|
android:backgroundTint="@color/widget_preview_event_purple" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
|
style="@style/WidgetPreviewWeekDay"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Sun"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="1"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<Space
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="6dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="widget_preview_surface">@android:color/system_neutral1_900</color>
|
<color name="widget_preview_surface">@android:color/system_accent2_700</color>
|
||||||
|
<color name="widget_preview_cell">@android:color/system_accent2_900</color>
|
||||||
|
<color name="widget_preview_outside">@android:color/system_neutral2_600</color>
|
||||||
<color name="widget_preview_on_surface">@android:color/system_neutral1_50</color>
|
<color name="widget_preview_on_surface">@android:color/system_neutral1_50</color>
|
||||||
<color name="widget_preview_variant">@android:color/system_neutral2_200</color>
|
<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_primary">@android:color/system_accent1_200</color>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="widget_preview_surface">#101316</color>
|
<color name="widget_preview_surface">#2B3742</color>
|
||||||
|
<color name="widget_preview_cell">#1C252D</color>
|
||||||
|
<color name="widget_preview_outside">#3A4651</color>
|
||||||
<color name="widget_preview_on_surface">#E1E3E6</color>
|
<color name="widget_preview_on_surface">#E1E3E6</color>
|
||||||
<color name="widget_preview_variant">#A8ADB2</color>
|
<color name="widget_preview_variant">#A8ADB2</color>
|
||||||
<color name="widget_preview_primary">#A3CBE2</color>
|
<color name="widget_preview_primary">#A3CBE2</color>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="widget_preview_surface">@android:color/system_neutral1_50</color>
|
<color name="widget_preview_surface">@android:color/system_accent2_100</color>
|
||||||
|
<color name="widget_preview_cell">@android:color/system_accent2_10</color>
|
||||||
|
<color name="widget_preview_outside">@android:color/system_neutral2_300</color>
|
||||||
<color name="widget_preview_on_surface">@android:color/system_neutral1_900</color>
|
<color name="widget_preview_on_surface">@android:color/system_neutral1_900</color>
|
||||||
<color name="widget_preview_variant">@android:color/system_neutral2_700</color>
|
<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_primary">@android:color/system_accent1_600</color>
|
||||||
|
|||||||
@@ -435,6 +435,65 @@
|
|||||||
<string name="widget_agenda_title">Upcoming</string>
|
<string name="widget_agenda_title">Upcoming</string>
|
||||||
<string name="widget_agenda_label">Calendula agenda</string>
|
<string name="widget_agenda_label">Calendula agenda</string>
|
||||||
<string name="widget_month_label">Calendula month</string>
|
<string name="widget_month_label">Calendula month</string>
|
||||||
|
<string name="widget_quick_add_label">Calendula quick add</string>
|
||||||
|
<string name="widget_date_label">Calendula date</string>
|
||||||
|
<string name="widget_week_strip_label">Calendula week strip</string>
|
||||||
|
<!-- A day in the week strip widget, for screen readers: "Wednesday, 7 October, 2 events" -->
|
||||||
|
<plurals name="widget_day_with_events">
|
||||||
|
<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_today_label">Calendula today</string>
|
||||||
|
<string name="widget_nothing_left_today">Nothing left today</string>
|
||||||
|
<string name="widget_countdown_label">Calendula countdown</string>
|
||||||
|
<string name="widget_countdown_now">Now</string>
|
||||||
|
<string name="widget_countdown_ending_now">Ending now</string>
|
||||||
|
<string name="widget_countdown_tomorrow">Tomorrow</string>
|
||||||
|
<string name="widget_countdown_ends_tomorrow">Ends tomorrow</string>
|
||||||
|
<!-- A running all-day event's last day: "Until Fri" -->
|
||||||
|
<string name="widget_countdown_until">Until %1$s</string>
|
||||||
|
<plurals name="widget_countdown_in_minutes">
|
||||||
|
<item quantity="one">in %d min</item>
|
||||||
|
<item quantity="other">in %d min</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="widget_countdown_in_hours">
|
||||||
|
<item quantity="one">in %d h</item>
|
||||||
|
<item quantity="other">in %d h</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="widget_countdown_in_days">
|
||||||
|
<item quantity="one">in %d day</item>
|
||||||
|
<item quantity="other">in %d days</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="widget_countdown_ends_minutes">
|
||||||
|
<item quantity="one">ends in %d min</item>
|
||||||
|
<item quantity="other">ends in %d min</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="widget_countdown_ends_hours">
|
||||||
|
<item quantity="one">ends in %d h</item>
|
||||||
|
<item quantity="other">ends in %d h</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="widget_countdown_ends_days">
|
||||||
|
<item quantity="one">ends in %d day</item>
|
||||||
|
<item quantity="other">ends in %d days</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="widget_countdown_choose">Tap to choose an event to count down to</string>
|
||||||
|
<!-- A pinned countdown whose event has no occurrence left: "Vacation is over" -->
|
||||||
|
<string name="widget_countdown_over">%1$s is over</string>
|
||||||
|
<string name="widget_config_countdown">Count down to</string>
|
||||||
|
<string name="widget_config_countdown_next">Next event</string>
|
||||||
|
<string name="widget_config_countdown_pinned">Chosen event</string>
|
||||||
|
<string name="widget_config_countdown_next_hint">The next timed event in this widget\'s calendars, or the one running now. All-day and declined events are skipped.</string>
|
||||||
|
<string name="widget_config_countdown_event">Event</string>
|
||||||
|
<string name="widget_config_countdown_event_none">None chosen yet</string>
|
||||||
|
<string name="widget_config_countdown_search">Search events</string>
|
||||||
|
<string name="widget_config_countdown_empty">No events in the coming year match.</string>
|
||||||
|
<string name="widget_config_countdown_load_failed">Couldn\'t load your events.</string>
|
||||||
|
<!-- A repeating event in the countdown's event list: "Next: Fri, 24 Oct · repeats" -->
|
||||||
|
<string name="widget_config_countdown_repeats">%1$s · repeats</string>
|
||||||
|
<string name="widget_month_day_label">Calendula month and day</string>
|
||||||
|
<string name="widget_day_empty">No events</string>
|
||||||
<string name="widget_refresh">Refresh</string>
|
<string name="widget_refresh">Refresh</string>
|
||||||
<string name="widget_new_event">New event</string>
|
<string name="widget_new_event">New event</string>
|
||||||
<string name="widget_needs_permission">Open Calendula to grant calendar access</string>
|
<string name="widget_needs_permission">Open Calendula to grant calendar access</string>
|
||||||
@@ -456,6 +515,14 @@
|
|||||||
<string name="widget_numbered">%1$s %2$d</string>
|
<string name="widget_numbered">%1$s %2$d</string>
|
||||||
<!-- A placed widget's background and theme: "Tinted · Follow app" -->
|
<!-- A placed widget's background and theme: "Tinted · Follow app" -->
|
||||||
<string name="widget_appearance_summary">%1$s · %2$s</string>
|
<string name="widget_appearance_summary">%1$s · %2$s</string>
|
||||||
|
<string name="widget_config_calendars">Calendars</string>
|
||||||
|
<string name="widget_config_calendars_hint">The calendars this widget shows. Calendars you hid in the app stay hidden here too.</string>
|
||||||
|
<string name="widget_config_calendars_all">All calendars</string>
|
||||||
|
<!-- How many calendars a widget shows: "2 of 5 calendars" -->
|
||||||
|
<plurals name="widget_config_calendars_some">
|
||||||
|
<item quantity="one">%1$d of %2$d calendar</item>
|
||||||
|
<item quantity="other">%1$d of %2$d calendars</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
<!-- Calendar filter (M3) -->
|
<!-- Calendar filter (M3) -->
|
||||||
<string name="filter_title">Calendars</string>
|
<string name="filter_title">Calendars</string>
|
||||||
@@ -554,6 +621,9 @@
|
|||||||
<string name="settings_past_events_sample_morning">Team review</string>
|
<string name="settings_past_events_sample_morning">Team review</string>
|
||||||
<string name="settings_past_events_sample_midday">Lunch with Robin</string>
|
<string name="settings_past_events_sample_midday">Lunch with Robin</string>
|
||||||
<string name="settings_past_events_sample_evening">Choir practice</string>
|
<string name="settings_past_events_sample_evening">Choir practice</string>
|
||||||
|
<!-- Stand-in event titles in the widget picker previews. Short, everyday entries. -->
|
||||||
|
<string name="widget_preview_sample_trip">Weekend trip</string>
|
||||||
|
<string name="widget_preview_sample_dentist">Dentist</string>
|
||||||
<string name="settings_agenda_header">Agenda</string>
|
<string name="settings_agenda_header">Agenda</string>
|
||||||
<string name="settings_agenda_range">Agenda range</string>
|
<string name="settings_agenda_range">Agenda range</string>
|
||||||
<string name="settings_agenda_range_hint">How far ahead the Agenda screen lists events.</string>
|
<string name="settings_agenda_range_hint">How far ahead the Agenda screen lists events.</string>
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Colours for the static widget-picker previews. Base = brand light fallback
|
<!-- Colours for the static widget-picker previews (Android 14 and below; 15+
|
||||||
(API < 31). values-v31 maps them to the Material You dynamic palette so the
|
shows the generated preview). Base = brand light fallback (API < 31).
|
||||||
preview matches the live Glance widget; -night holds the dark variants. -->
|
values-v31 maps them to the Material You dynamic palette so the preview
|
||||||
|
matches the live Glance widget; -night holds the dark variants. -->
|
||||||
<resources>
|
<resources>
|
||||||
<color name="widget_preview_surface">#FBFCFE</color>
|
<color name="widget_preview_surface">#D6E4F0</color>
|
||||||
|
<color name="widget_preview_cell">#EEF3F8</color>
|
||||||
|
<color name="widget_preview_outside">#C2CCD6</color>
|
||||||
<color name="widget_preview_on_surface">#191C1F</color>
|
<color name="widget_preview_on_surface">#191C1F</color>
|
||||||
<color name="widget_preview_variant">#6E7479</color>
|
<color name="widget_preview_variant">#6E7479</color>
|
||||||
<color name="widget_preview_primary">#3B5364</color>
|
<color name="widget_preview_primary">#3B5364</color>
|
||||||
<color name="widget_preview_on_primary">#FFFFFF</color>
|
<color name="widget_preview_on_primary">#FFFFFF</color>
|
||||||
|
<color name="widget_preview_event_blue">#039BE5</color>
|
||||||
|
<color name="widget_preview_event_green">#33B679</color>
|
||||||
|
<color name="widget_preview_event_purple">#8E24AA</color>
|
||||||
|
<color name="widget_preview_event_orange">#F4511E</color>
|
||||||
|
<color name="widget_preview_event_red">#D50000</color>
|
||||||
|
<color name="widget_preview_on_event_light">#000000</color>
|
||||||
|
<color name="widget_preview_on_event_dark">#FFFFFF</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Shared looks for the static widget-picker previews. -->
|
||||||
|
<resources>
|
||||||
|
<style name="WidgetPreviewHeaderButton">
|
||||||
|
<item name="android:layout_width">24dp</item>
|
||||||
|
<item name="android:layout_height">22dp</item>
|
||||||
|
<item name="android:layout_marginStart">4dp</item>
|
||||||
|
<item name="android:background">@drawable/preview_widget_cell_bg</item>
|
||||||
|
<item name="android:gravity">center</item>
|
||||||
|
<item name="android:textColor">@color/widget_preview_on_surface</item>
|
||||||
|
<item name="android:textSize">12sp</item>
|
||||||
|
</style>
|
||||||
|
<style name="WidgetPreviewAddButton" parent="WidgetPreviewHeaderButton">
|
||||||
|
<item name="android:layout_width">30dp</item>
|
||||||
|
<item name="android:background">@drawable/preview_widget_today_bg</item>
|
||||||
|
<item name="android:textColor">@color/widget_preview_on_primary</item>
|
||||||
|
<item name="android:textSize">15sp</item>
|
||||||
|
</style>
|
||||||
|
<style name="WidgetPreviewWeekDay">
|
||||||
|
<item name="android:layout_width">0dp</item>
|
||||||
|
<item name="android:layout_height">match_parent</item>
|
||||||
|
<item name="android:layout_weight">1</item>
|
||||||
|
<item name="android:layout_marginHorizontal">2dp</item>
|
||||||
|
<item name="android:background">@drawable/preview_widget_cell_bg</item>
|
||||||
|
<item name="android:gravity">center</item>
|
||||||
|
<item name="android:textColor">@color/widget_preview_on_surface</item>
|
||||||
|
<item name="android:textSize">12sp</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="40dp"
|
||||||
|
android:targetCellWidth="2"
|
||||||
|
android:targetCellHeight="1"
|
||||||
|
android:minResizeWidth="110dp"
|
||||||
|
android:minResizeHeight="40dp"
|
||||||
|
android:resizeMode="horizontal|vertical"
|
||||||
|
android:widgetCategory="home_screen"
|
||||||
|
android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity"
|
||||||
|
android:widgetFeatures="reconfigurable"
|
||||||
|
android:description="@string/widget_countdown_label"
|
||||||
|
android:updatePeriodMillis="1800000"
|
||||||
|
android:previewLayout="@layout/widget_preview_countdown"
|
||||||
|
android:initialLayout="@layout/glance_default_loading_layout" />
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:minWidth="40dp"
|
||||||
|
android:minHeight="40dp"
|
||||||
|
android:targetCellWidth="1"
|
||||||
|
android:targetCellHeight="1"
|
||||||
|
android:resizeMode="horizontal|vertical"
|
||||||
|
android:widgetCategory="home_screen"
|
||||||
|
android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity"
|
||||||
|
android:widgetFeatures="reconfigurable"
|
||||||
|
android:description="@string/widget_date_label"
|
||||||
|
android:updatePeriodMillis="1800000"
|
||||||
|
android:previewLayout="@layout/widget_preview_date"
|
||||||
|
android:initialLayout="@layout/glance_default_loading_layout" />
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:minWidth="250dp"
|
||||||
|
android:minHeight="110dp"
|
||||||
|
android:targetCellWidth="4"
|
||||||
|
android:targetCellHeight="2"
|
||||||
|
android:minResizeWidth="250dp"
|
||||||
|
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_month_day_label"
|
||||||
|
android:updatePeriodMillis="1800000"
|
||||||
|
android:previewLayout="@layout/widget_preview_month_day"
|
||||||
|
android:initialLayout="@layout/glance_default_loading_layout" />
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:minWidth="40dp"
|
||||||
|
android:minHeight="40dp"
|
||||||
|
android:targetCellWidth="1"
|
||||||
|
android:targetCellHeight="1"
|
||||||
|
android:resizeMode="horizontal|vertical"
|
||||||
|
android:widgetCategory="home_screen"
|
||||||
|
android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity"
|
||||||
|
android:widgetFeatures="reconfigurable"
|
||||||
|
android:description="@string/widget_quick_add_label"
|
||||||
|
android:updatePeriodMillis="1800000"
|
||||||
|
android:previewLayout="@layout/widget_preview_quick_add"
|
||||||
|
android:initialLayout="@layout/glance_default_loading_layout" />
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:minWidth="250dp"
|
||||||
|
android:minHeight="40dp"
|
||||||
|
android:targetCellWidth="4"
|
||||||
|
android:targetCellHeight="1"
|
||||||
|
android:minResizeWidth="180dp"
|
||||||
|
android:minResizeHeight="40dp"
|
||||||
|
android:maxResizeWidth="420dp"
|
||||||
|
android:maxResizeHeight="230dp"
|
||||||
|
android:resizeMode="horizontal|vertical"
|
||||||
|
android:widgetCategory="home_screen"
|
||||||
|
android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity"
|
||||||
|
android:widgetFeatures="reconfigurable"
|
||||||
|
android:description="@string/widget_today_label"
|
||||||
|
android:updatePeriodMillis="1800000"
|
||||||
|
android:previewLayout="@layout/widget_preview_today"
|
||||||
|
android:initialLayout="@layout/glance_default_loading_layout" />
|
||||||
@@ -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,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:minWidth="250dp"
|
||||||
|
android:minHeight="40dp"
|
||||||
|
android:targetCellWidth="4"
|
||||||
|
android:targetCellHeight="1"
|
||||||
|
android:minResizeWidth="180dp"
|
||||||
|
android:minResizeHeight="40dp"
|
||||||
|
android:resizeMode="horizontal|vertical"
|
||||||
|
android:widgetCategory="home_screen"
|
||||||
|
android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity"
|
||||||
|
android:widgetFeatures="reconfigurable"
|
||||||
|
android:description="@string/widget_week_strip_label"
|
||||||
|
android:updatePeriodMillis="1800000"
|
||||||
|
android:previewLayout="@layout/widget_preview_week_strip"
|
||||||
|
android:initialLayout="@layout/glance_default_loading_layout" />
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class WeekOfTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the week starts on the configured day and holds today`() {
|
||||||
|
val wednesday = LocalDate(2026, 10, 21)
|
||||||
|
assertThat(weekOf(wednesday, DayOfWeek.MONDAY)).isEqualTo(LocalDate(2026, 10, 19)..LocalDate(2026, 10, 25))
|
||||||
|
assertThat(weekOf(wednesday, DayOfWeek.SUNDAY)).isEqualTo(LocalDate(2026, 10, 18)..LocalDate(2026, 10, 24))
|
||||||
|
assertThat(weekOf(wednesday, DayOfWeek.WEDNESDAY).start).isEqualTo(wednesday)
|
||||||
|
assertThat(weekOf(wednesday, DayOfWeek.THURSDAY).start).isEqualTo(LocalDate(2026, 10, 15))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.countdown
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventResponse
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.time.Duration.Companion.days
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
class CountdownTest {
|
||||||
|
|
||||||
|
private val zone = TimeZone.UTC
|
||||||
|
private val now = Instant.parse("2026-10-21T12:00:00Z")
|
||||||
|
|
||||||
|
private fun event(
|
||||||
|
start: Instant,
|
||||||
|
end: Instant = start + 1.hours,
|
||||||
|
id: Long = 1,
|
||||||
|
allDay: Boolean = false,
|
||||||
|
response: EventResponse = EventResponse.Going,
|
||||||
|
) = EventInstance(
|
||||||
|
instanceId = id, eventId = id, calendarId = 1, title = "e$id",
|
||||||
|
start = start, end = end, isAllDay = allDay, color = 0, location = null, response = response,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `labels step from days to hours to minutes to now`() {
|
||||||
|
assertThat(countdownLabel(event(now + 30.seconds), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.NOW, 0))
|
||||||
|
assertThat(countdownLabel(event(now + 24.minutes + 10.seconds), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.MINUTES, 25))
|
||||||
|
assertThat(countdownLabel(event(now + 2.hours + 40.minutes), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.HOURS, 3))
|
||||||
|
assertThat(countdownLabel(event(now + 1.hours + 10.minutes), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.HOURS, 1))
|
||||||
|
// Tomorrow 23:00 is 35 h away but one calendar day.
|
||||||
|
assertThat(countdownLabel(event(now + 35.hours), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.DAYS, 1))
|
||||||
|
assertThat(countdownLabel(event(now + 12.days), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.DAYS, 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a running event counts to its end`() {
|
||||||
|
val running = event(start = now - 20.minutes, end = now + 10.minutes)
|
||||||
|
assertThat(countdownLabel(running, now, zone)).isEqualTo(CountdownLabel(true, CountdownUnit.MINUTES, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the next redraw lands where the label changes`() {
|
||||||
|
// "in 25 min" turns into "in 24 min" a minute later.
|
||||||
|
assertThat(nextLabelChange(event(now + 25.minutes), now, zone)).isEqualTo(now + 1.minutes)
|
||||||
|
// "in 3 h" (2 h 40 min) holds until 2 h 30 min are left.
|
||||||
|
assertThat(nextLabelChange(event(now + 2.hours + 40.minutes), now, zone)).isEqualTo(now + 10.minutes)
|
||||||
|
// "in 1 h" holds until the last hour, then counts minutes.
|
||||||
|
assertThat(nextLabelChange(event(now + 1.hours + 10.minutes), now, zone)).isEqualTo(now + 10.minutes)
|
||||||
|
// Days change at midnight.
|
||||||
|
assertThat(nextLabelChange(event(now + 5.days), now, zone)).isEqualTo(Instant.parse("2026-10-22T00:00:00Z"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `next mode skips all-day, declined and finished events`() {
|
||||||
|
val allDay = event(now + 1.hours, id = 1, allDay = true)
|
||||||
|
val declined = event(now + 1.hours, id = 2, response = EventResponse.Declined)
|
||||||
|
val over = event(now - 2.hours, now - 1.hours, id = 3)
|
||||||
|
val later = event(now + 3.hours, id = 4)
|
||||||
|
val running = event(now - 1.hours, now + 2.hours, id = 5)
|
||||||
|
assertThat(pickNextEvent(listOf(allDay, declined, over, later), now)?.eventId).isEqualTo(4)
|
||||||
|
// A running event ending before the next one starts comes first.
|
||||||
|
assertThat(pickNextEvent(listOf(later, running), now)?.eventId).isEqualTo(5)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a pinned series follows its next occurrence`() {
|
||||||
|
val past = event(now - 7.days)
|
||||||
|
val next = event(now + 7.days)
|
||||||
|
assertThat(pickPinnedOccurrence(listOf(past, next), now, zone)).isEqualTo(next)
|
||||||
|
assertThat(pickPinnedOccurrence(listOf(past), now, zone)).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day event counts in local days west of UTC`() {
|
||||||
|
val newYork = TimeZone.of("America/New_York")
|
||||||
|
// 21 Oct, 20:00 in New York; the vacation is all day on the 23rd.
|
||||||
|
val evening = Instant.parse("2026-10-22T00:00:00Z")
|
||||||
|
val vacation = event(Instant.parse("2026-10-23T00:00:00Z"), Instant.parse("2026-10-24T00:00:00Z"), allDay = true)
|
||||||
|
val counted = vacation.counted(newYork)
|
||||||
|
assertThat(counted.start).isEqualTo(Instant.parse("2026-10-23T04:00:00Z"))
|
||||||
|
assertThat(countdownLabel(counted, evening, newYork)).isEqualTo(CountdownLabel(false, CountdownUnit.DAYS, 2))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.split
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class MonthGridDaysTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `whole weeks from the week start cover the month and no more`() {
|
||||||
|
// October 2026 starts on a Thursday and has 31 days.
|
||||||
|
val october = 2026 * 12 + 9
|
||||||
|
val monday = monthGridDays(october, DayOfWeek.MONDAY)
|
||||||
|
assertThat(monday.first()).isEqualTo(LocalDate(2026, 9, 28))
|
||||||
|
assertThat(monday.last()).isEqualTo(LocalDate(2026, 11, 1))
|
||||||
|
assertThat(monday).hasSize(35)
|
||||||
|
|
||||||
|
// February 2027 starts on a Monday and fills exactly four weeks.
|
||||||
|
val february = 2027 * 12 + 1
|
||||||
|
assertThat(monthGridDays(february, DayOfWeek.MONDAY)).hasSize(28)
|
||||||
|
assertThat(monthGridDays(february, DayOfWeek.SUNDAY)).hasSize(35)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.today
|
||||||
|
|
||||||
|
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 TodayRowsTest {
|
||||||
|
|
||||||
|
private val now = Instant.parse("2026-10-21T12:00:00Z")
|
||||||
|
private val today = LocalDate(2026, 10, 21)
|
||||||
|
private val tomorrow = LocalDate(2026, 10, 22)
|
||||||
|
|
||||||
|
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(past: PastEventDisplay, vararg days: AgendaDay) = DaysWidgetData.Ready(
|
||||||
|
today = today,
|
||||||
|
firstDay = today,
|
||||||
|
lastDay = tomorrow,
|
||||||
|
days = days.toList(),
|
||||||
|
weekStart = DayOfWeek.MONDAY,
|
||||||
|
is24Hour = true,
|
||||||
|
soften = true,
|
||||||
|
options = AgendaWidgetOptions(AgendaRange.Day, WidgetSize.SMALL, true, past),
|
||||||
|
now = now,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val ended = event(1, now - 3.hours, now - 1.hours)
|
||||||
|
private val later = event(2, now + 1.hours, now + 2.hours)
|
||||||
|
private val next = event(3, now + 22.hours, now + 23.hours)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an event still to come lists today, finished ones as the past setting says`() {
|
||||||
|
val days = arrayOf(AgendaDay(today, listOf(ended, later)), AgendaDay(tomorrow, listOf(next)))
|
||||||
|
assertThat(todayRows(data(PastEventDisplay.DIM, *days))).isEqualTo(TodayRows.Today(listOf(ended, later)))
|
||||||
|
assertThat(todayRows(data(PastEventDisplay.HIDE, *days))).isEqualTo(TodayRows.Today(listOf(later)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `once today is over it looks ahead to tomorrow`() {
|
||||||
|
val over = data(PastEventDisplay.SHOW, AgendaDay(today, listOf(ended)), AgendaDay(tomorrow, listOf(next)))
|
||||||
|
assertThat(todayRows(over)).isEqualTo(TodayRows.NothingLeft(listOf(next)))
|
||||||
|
assertThat(todayRows(data(PastEventDisplay.SHOW))).isEqualTo(TodayRows.NothingLeft(emptyList()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
Submodule floret-kit updated: f2ffaed925...5716402e72
Reference in New Issue
Block a user