Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f81bb9ea5 | ||
|
|
a08ad15c4a | ||
|
|
c9cc40ff60 | ||
|
|
343a8904ea | ||
|
|
075940d29e | ||
|
|
670cdb226a | ||
|
|
a8da8bc7a3 | ||
|
|
cfcba283f1 | ||
|
|
85d4dc535c | ||
|
|
edf80fe8b8 | ||
|
|
0ecd6de27b | ||
|
|
610409012f | ||
|
|
7e45656479 | ||
|
|
94b04c1365 | ||
|
|
d1557825c6 | ||
|
|
d43a741c93 | ||
|
|
6da0648b77 | ||
|
|
3b48987ab7 | ||
|
|
04a677dc10 | ||
|
|
46ca2db63a | ||
|
|
a87ebc33b6 | ||
|
|
3471447ab6 | ||
|
|
b206e0e6a5 | ||
|
|
35f5dc1666 | ||
|
|
225901ceee | ||
|
|
77e103c66c |
@@ -181,6 +181,7 @@ dependencies {
|
|||||||
implementation("de.jeanlucmakiola.floret:core-android")
|
implementation("de.jeanlucmakiola.floret:core-android")
|
||||||
implementation("de.jeanlucmakiola.floret:identity")
|
implementation("de.jeanlucmakiola.floret:identity")
|
||||||
implementation("de.jeanlucmakiola.floret:components")
|
implementation("de.jeanlucmakiola.floret:components")
|
||||||
|
implementation("de.jeanlucmakiola.floret:glance")
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
|
||||||
debugImplementation(libs.androidx.ui.tooling)
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
|
|||||||
+140
@@ -0,0 +1,140 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.month
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.ContentUris
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Parcel
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.glance.ExperimentalGlanceApi
|
||||||
|
import androidx.glance.appwidget.compose
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import androidx.test.rule.GrantPermissionRule
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the month widget for a packed month through Glance's own translator
|
||||||
|
* (#372) and checks what one size costs on the wire: the host's binder buffer is
|
||||||
|
* 1 MB for every widget update at once, and Exact sends one copy per size (#214).
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalGlanceApi::class)
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class MonthWidgetPayloadTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val permissionRule: GrantPermissionRule =
|
||||||
|
GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)
|
||||||
|
|
||||||
|
private val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||||
|
private val context = instrumentation.targetContext
|
||||||
|
private var calendarId = -1L
|
||||||
|
|
||||||
|
private fun Uri.asSyncAdapter(): Uri = buildUpon()
|
||||||
|
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
|
||||||
|
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT)
|
||||||
|
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun packTheMonth() {
|
||||||
|
val calendar = ContentValues().apply {
|
||||||
|
put(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT)
|
||||||
|
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||||
|
put(CalendarContract.Calendars.NAME, ACCOUNT)
|
||||||
|
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, ACCOUNT)
|
||||||
|
put(CalendarContract.Calendars.CALENDAR_COLOR, 0xFF3F51B5.toInt())
|
||||||
|
put(CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL, CalendarContract.Calendars.CAL_ACCESS_OWNER)
|
||||||
|
put(CalendarContract.Calendars.OWNER_ACCOUNT, ACCOUNT)
|
||||||
|
put(CalendarContract.Calendars.VISIBLE, 1)
|
||||||
|
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
|
||||||
|
}
|
||||||
|
val uri = context.contentResolver.insert(CalendarContract.Calendars.CONTENT_URI.asSyncAdapter(), calendar)
|
||||||
|
calendarId = ContentUris.parseId(requireNotNull(uri))
|
||||||
|
|
||||||
|
val zone = ZoneId.systemDefault()
|
||||||
|
val first = LocalDate.now(zone).withDayOfMonth(1).minusDays(7)
|
||||||
|
val events = (0 until 49).flatMap { offset ->
|
||||||
|
val day = first.plusDays(offset.toLong())
|
||||||
|
(0 until EVENTS_PER_DAY).map { i ->
|
||||||
|
val start = day.atTime(8 + i, 0).atZone(zone).toInstant().toEpochMilli()
|
||||||
|
ContentValues().apply {
|
||||||
|
put(CalendarContract.Events.CALENDAR_ID, calendarId)
|
||||||
|
put(CalendarContract.Events.TITLE, "Busy $i")
|
||||||
|
put(CalendarContract.Events.DTSTART, start)
|
||||||
|
put(CalendarContract.Events.DTEND, start + 30 * 60_000L)
|
||||||
|
put(CalendarContract.Events.EVENT_TIMEZONE, zone.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.contentResolver.bulkInsert(CalendarContract.Events.CONTENT_URI.asSyncAdapter(), events.toTypedArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun removeCalendar() {
|
||||||
|
if (calendarId < 0) return
|
||||||
|
context.contentResolver.delete(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId).asSyncAdapter(),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aPackedMonthRendersWithinItsPayload() = runBlocking {
|
||||||
|
for (size in listOf(DpSize(250.dp, 250.dp), DpSize(320.dp, 360.dp), DpSize(420.dp, 520.dp), DpSize(600.dp, 1000.dp))) {
|
||||||
|
val views = MonthWidget().compose(context, size = size)
|
||||||
|
val bytes = Parcel.obtain().let { parcel ->
|
||||||
|
try {
|
||||||
|
views.writeToParcel(parcel, 0)
|
||||||
|
parcel.dataSize()
|
||||||
|
} finally {
|
||||||
|
parcel.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var dayNumbers = 0
|
||||||
|
var bars = 0
|
||||||
|
instrumentation.runOnMainSync {
|
||||||
|
val root = views.apply(context, FrameLayout(context))
|
||||||
|
fun walk(view: View) {
|
||||||
|
if (view.id in DAY_IDS && view is TextView && view.text.isNotEmpty()) dayNumbers += 1
|
||||||
|
if (view.id == R.id.bar_title) bars += 1
|
||||||
|
if (view is ViewGroup) for (i in 0 until view.childCount) walk(view.getChildAt(i))
|
||||||
|
}
|
||||||
|
walk(root)
|
||||||
|
}
|
||||||
|
Log.i(TAG, "size=$size bytes=$bytes days=$dayNumbers bars=$bars")
|
||||||
|
// The grid itself rendered, not Glance's error layout: a number in every day of every week.
|
||||||
|
assertThat(dayNumbers % 7).isEqualTo(0)
|
||||||
|
assertThat(dayNumbers).isAtLeast(28)
|
||||||
|
assertThat(bytes).isAtMost(MAX_BYTES_PER_SIZE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "MonthWidgetPayload"
|
||||||
|
const val ACCOUNT = "calendula-widget-payload-test"
|
||||||
|
const val EVENTS_PER_DAY = 6
|
||||||
|
|
||||||
|
val DAY_IDS = setOf(R.id.day_0, R.id.day_1, R.id.day_2, R.id.day_3, R.id.day_4, R.id.day_5, R.id.day_6)
|
||||||
|
|
||||||
|
/** A quarter of the host's buffer, leaving room for a second size and every other widget. */
|
||||||
|
const val MAX_BYTES_PER_SIZE = 256 * 1024
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -318,6 +318,16 @@
|
|||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
<!-- Home-screen widgets (Glance). Exported: the launcher/host binds them. -->
|
<!-- Home-screen widgets (Glance). Exported: the launcher/host binds them. -->
|
||||||
|
<activity
|
||||||
|
android:name=".widget.config.WidgetConfigActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:theme="@style/Theme.Calendula.WidgetConfig">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".widget.agenda.AgendaWidgetReceiver"
|
android:name=".widget.agenda.AgendaWidgetReceiver"
|
||||||
android:label="@string/widget_agenda_label"
|
android:label="@string/widget_agenda_label"
|
||||||
@@ -342,13 +352,103 @@
|
|||||||
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 (our writes and external sync).
|
PROVIDER_CHANGED on any data change (external sync; the app's own
|
||||||
The day boundary arrives as the app's own ROLLOVER alarm (#228), by
|
writes redraw directly). The day boundary arrives as the app's own
|
||||||
explicit PendingIntent, so it needs no filter here; DATE_CHANGED is
|
ROLLOVER alarm (#228), by explicit PendingIntent, so it needs no
|
||||||
a free extra only, since Android 8+ withholds it from manifest
|
filter here; DATE_CHANGED is a free extra only, since Android 8+
|
||||||
receivers. The four below re-arm that alarm: TIME_SET /
|
withholds it from manifest receivers. TIME_SET / TIMEZONE_CHANGED
|
||||||
TIMEZONE_CHANGED move the boundary, boot / package-replace wipe it.
|
move the boundary, boot / package-replace wipe the alarm, and
|
||||||
|
LOCALE_CHANGED redraws the labels in the new language.
|
||||||
Exported: the system broadcasts arrive from outside the app. -->
|
Exported: the system broadcasts arrive from outside the app. -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".widget.WidgetUpdateReceiver"
|
android:name=".widget.WidgetUpdateReceiver"
|
||||||
@@ -365,6 +465,7 @@
|
|||||||
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
|
<action android:name="android.intent.action.LOCALE_CHANGED" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ 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.WidgetRolloverScheduler
|
import de.jeanlucmakiola.calendula.widget.publishCalendulaWidgetPreviews
|
||||||
|
import de.jeanlucmakiola.calendula.widget.syncWidgetRollover
|
||||||
|
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
|
||||||
import de.jeanlucmakiola.floret.crash.CrashConfig
|
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -43,6 +45,8 @@ class CalendulaApp : Application() {
|
|||||||
chooseIssueUrl = getString(R.string.report_issue_choose_url),
|
chooseIssueUrl = getString(R.string.report_issue_choose_url),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
// Before anything that writes, so the widgets see every change (#420).
|
||||||
|
widgetEntryPoint().widgetRefreshCoordinator().start()
|
||||||
reconcileAutoBackup()
|
reconcileAutoBackup()
|
||||||
reconcileSpecialDates()
|
reconcileSpecialDates()
|
||||||
reconcileCalendarVisibility()
|
reconcileCalendarVisibility()
|
||||||
@@ -55,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 {
|
||||||
WidgetRolloverScheduler.sync(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()
|
||||||
return resolver.query(
|
|
||||||
instancesUri(beginMillis, endMillis),
|
|
||||||
InstanceProjection.COLUMNS,
|
|
||||||
// Hide cancelled occurrences: "delete only this event" writes a
|
// Hide cancelled occurrences: "delete only this event" writes a
|
||||||
// cancelled exception for the one instance (#47). A NULL status is 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
|
// normal, un-cancelled event, so it must survive the filter — a bare
|
||||||
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
|
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
|
||||||
// DELETED = 0: a provider that soft-deletes may keep the row's
|
// DELETED = 0: a provider that soft-deletes may keep the row's
|
||||||
// instances until a purge (#364).
|
// instances until a purge (#364).
|
||||||
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
|
val visible = "(${CalendarContract.Instances.STATUS} IS NULL OR " +
|
||||||
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}) AND " +
|
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}) AND " +
|
||||||
"${CalendarContract.Events.DELETED} = 0",
|
"${CalendarContract.Events.DELETED} = 0"
|
||||||
null,
|
return resolver.query(
|
||||||
|
instancesUri(beginMillis, endMillis),
|
||||||
|
InstanceProjection.COLUMNS,
|
||||||
|
if (extraSelection == null) visible else "$visible AND $extraSelection",
|
||||||
|
args,
|
||||||
CalendarContract.Instances.BEGIN + " ASC",
|
CalendarContract.Instances.BEGIN + " ASC",
|
||||||
)?.use { c -> c.mapRowsNotNull { toEventInstance() } } ?: emptyList()
|
)?.use { c -> c.mapRowsNotNull { toEventInstance() } } ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,15 @@ 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()
|
||||||
|
|
||||||
|
/** Emits on the app's own writes and visibility changes; not on provider notifications or [refresh]. */
|
||||||
|
val localChanges: Flow<Unit>
|
||||||
|
|
||||||
suspend fun eventDetail(eventId: Long): EventDetail
|
suspend fun eventDetail(eventId: Long): EventDetail
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -78,15 +78,28 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
ticks.tryEmit(Unit)
|
ticks.tryEmit(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val writes = MutableSharedFlow<Unit>(
|
||||||
|
replay = 0,
|
||||||
|
extraBufferCapacity = 1,
|
||||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||||
|
)
|
||||||
|
|
||||||
/** Run a provider write on [io], then re-query whether or not it threw. */
|
/** Run a provider write on [io], then re-query whether or not it threw. */
|
||||||
private suspend fun <T> write(block: suspend () -> T): T = withContext(io) {
|
private suspend fun <T> write(block: suspend () -> T): T = withContext(io) {
|
||||||
try {
|
try {
|
||||||
block()
|
block()
|
||||||
} finally {
|
} finally {
|
||||||
refresh()
|
refresh()
|
||||||
|
writes.tryEmit(Unit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override val localChanges: Flow<Unit> = merge(
|
||||||
|
writes,
|
||||||
|
prefs.pendingDisabledCalendarIds.distinctUntilChanged().drop(1).map {},
|
||||||
|
prefs.hiddenCalendarIds.distinctUntilChanged().drop(1).map {},
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-query signal for everything filtered by visibility: the provider's own
|
* Re-query signal for everything filtered by visibility: the provider's own
|
||||||
* notifications, plus every change to the pending switch-off set.
|
* notifications, plus every change to the pending switch-off set.
|
||||||
@@ -181,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)
|
||||||
|
|||||||
@@ -154,8 +154,6 @@ class SettingsPrefs @Inject constructor(
|
|||||||
val dragToReschedule: Flow<Boolean> = prefs.flow(SettingsKeys.dragToReschedule)
|
val dragToReschedule: Flow<Boolean> = prefs.flow(SettingsKeys.dragToReschedule)
|
||||||
val tapOpensEditor: Flow<Boolean> = prefs.flow(SettingsKeys.tapOpensEditor)
|
val tapOpensEditor: Flow<Boolean> = prefs.flow(SettingsKeys.tapOpensEditor)
|
||||||
val agendaScreenRange: Flow<AgendaRange> = prefs.flow(SettingsKeys.agendaScreenRange)
|
val agendaScreenRange: Flow<AgendaRange> = prefs.flow(SettingsKeys.agendaScreenRange)
|
||||||
val agendaWidgetRange: Flow<AgendaRange> = prefs.flow(SettingsKeys.agendaWidgetRange)
|
|
||||||
val widgetSize: Flow<WidgetSize> = prefs.flow(SettingsKeys.widgetSize)
|
|
||||||
val agendaShowRangeBar: Flow<Boolean> = prefs.flow(SettingsKeys.agendaShowRangeBar)
|
val agendaShowRangeBar: Flow<Boolean> = prefs.flow(SettingsKeys.agendaShowRangeBar)
|
||||||
val agendaShowToday: Flow<Boolean> = prefs.flow(SettingsKeys.agendaShowToday)
|
val agendaShowToday: Flow<Boolean> = prefs.flow(SettingsKeys.agendaShowToday)
|
||||||
val defaultView: Flow<CalendarView> = prefs.flow(SettingsKeys.defaultView)
|
val defaultView: Flow<CalendarView> = prefs.flow(SettingsKeys.defaultView)
|
||||||
@@ -231,8 +229,6 @@ class SettingsPrefs @Inject constructor(
|
|||||||
suspend fun setDragToReschedule(enabled: Boolean) = prefs.set(SettingsKeys.dragToReschedule, enabled)
|
suspend fun setDragToReschedule(enabled: Boolean) = prefs.set(SettingsKeys.dragToReschedule, enabled)
|
||||||
suspend fun setTapOpensEditor(enabled: Boolean) = prefs.set(SettingsKeys.tapOpensEditor, enabled)
|
suspend fun setTapOpensEditor(enabled: Boolean) = prefs.set(SettingsKeys.tapOpensEditor, enabled)
|
||||||
suspend fun setAgendaScreenRange(range: AgendaRange) = prefs.set(SettingsKeys.agendaScreenRange, range)
|
suspend fun setAgendaScreenRange(range: AgendaRange) = prefs.set(SettingsKeys.agendaScreenRange, range)
|
||||||
suspend fun setAgendaWidgetRange(range: AgendaRange) = prefs.set(SettingsKeys.agendaWidgetRange, range)
|
|
||||||
suspend fun setWidgetSize(size: WidgetSize) = prefs.set(SettingsKeys.widgetSize, size)
|
|
||||||
suspend fun setAgendaShowRangeBar(enabled: Boolean) = prefs.set(SettingsKeys.agendaShowRangeBar, enabled)
|
suspend fun setAgendaShowRangeBar(enabled: Boolean) = prefs.set(SettingsKeys.agendaShowRangeBar, enabled)
|
||||||
suspend fun setAgendaShowToday(enabled: Boolean) = prefs.set(SettingsKeys.agendaShowToday, enabled)
|
suspend fun setAgendaShowToday(enabled: Boolean) = prefs.set(SettingsKeys.agendaShowToday, enabled)
|
||||||
suspend fun setDefaultView(view: CalendarView) = prefs.set(SettingsKeys.defaultView, view)
|
suspend fun setDefaultView(view: CalendarView) = prefs.set(SettingsKeys.defaultView, view)
|
||||||
@@ -497,13 +493,16 @@ object SettingsKeys {
|
|||||||
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
||||||
val agendaScreenRange: Pref<AgendaRange> = agendaRangePref("agenda_screen_range")
|
val agendaScreenRange: Pref<AgendaRange> = agendaRangePref("agenda_screen_range")
|
||||||
|
|
||||||
/** How far ahead the agenda **widget** shows events (v2.11). */
|
/**
|
||||||
|
* How far ahead the agenda **widget** shows events (v2.11). Read-only since
|
||||||
|
* widgets keep their own options (#292): the default for a widget that has
|
||||||
|
* none yet.
|
||||||
|
*/
|
||||||
val agendaWidgetRange: Pref<AgendaRange> = agendaRangePref("agenda_widget_range")
|
val agendaWidgetRange: Pref<AgendaRange> = agendaRangePref("agenda_widget_range")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The size step the agenda widget draws its text at (#51). [WidgetSize.SMALL]
|
* The size step the agenda widget draws its text at (#51). [WidgetSize.SMALL]
|
||||||
* reproduces its original metrics. The month widget takes no size setting —
|
* reproduces its original metrics. Read-only like [agendaWidgetRange] (#292).
|
||||||
* it divides the width it is given by seven (#103).
|
|
||||||
*/
|
*/
|
||||||
val widgetSize: Pref<WidgetSize> = enumPref("widget_size", WidgetSize.SMALL)
|
val widgetSize: Pref<WidgetSize> = enumPref("widget_size", WidgetSize.SMALL)
|
||||||
|
|
||||||
@@ -511,8 +510,8 @@ object SettingsKeys {
|
|||||||
val agendaShowRangeBar: Pref<Boolean> = booleanPref("agenda_show_range_bar", true)
|
val agendaShowRangeBar: Pref<Boolean> = booleanPref("agenda_show_range_bar", true)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the agenda (screen and widget) always anchors today at the top with
|
* Whether the agenda screen always anchors today at the top with a "nothing
|
||||||
* a "nothing left today" placeholder (issue #35).
|
* left today" placeholder (issue #35), and the default for a widget (#292).
|
||||||
*/
|
*/
|
||||||
val agendaShowToday: Pref<Boolean> = booleanPref("agenda_show_today", true)
|
val agendaShowToday: Pref<Boolean> = booleanPref("agenda_show_today", true)
|
||||||
|
|
||||||
|
|||||||
@@ -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() },
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ fun SettingsScreen(
|
|||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = overlayExit,
|
exit = overlayExit,
|
||||||
) {
|
) {
|
||||||
WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Widgets)
|
WidgetsScreen(onBack = { section = null }, visible = section == SettingsSection.Widgets)
|
||||||
}
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = section == SettingsSection.Licences,
|
visible = section == SettingsSection.Licences,
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
|
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings screen state (M4). Persisted preferences are instant to read, so
|
* Settings screen state (M4). Persisted preferences are instant to read, so
|
||||||
@@ -51,14 +50,10 @@ data class SettingsUiState(
|
|||||||
val tapOpensEditor: Boolean = false,
|
val tapOpensEditor: Boolean = false,
|
||||||
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
||||||
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
||||||
/** How far ahead the agenda widget shows events (v2.11). */
|
/** Whether the agenda screen always anchors today at the top (#35); widgets have their own (#292). */
|
||||||
val agendaWidgetRange: AgendaRange = AgendaRange.Month,
|
|
||||||
/** Whether the agenda (screen + widget) always anchors today at the top (#35). */
|
|
||||||
val agendaShowToday: Boolean = true,
|
val agendaShowToday: Boolean = true,
|
||||||
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */
|
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */
|
||||||
val agendaShowRangeBar: Boolean = true,
|
val agendaShowRangeBar: Boolean = true,
|
||||||
/** The size step the agenda widget draws its text at (#51). */
|
|
||||||
val widgetSize: WidgetSize = WidgetSize.SMALL,
|
|
||||||
/** The calendar view the app opens on, and the home of the view back stack (M1). */
|
/** The calendar view the app opens on, and the home of the view back stack (M1). */
|
||||||
val defaultView: CalendarView = CalendarView.Week,
|
val defaultView: CalendarView = CalendarView.Week,
|
||||||
/** Which views the top-bar quick-switch button cycles through, and their order (#24). */
|
/** Which views the top-bar quick-switch button cycles through, and their order (#24). */
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ package de.jeanlucmakiola.calendula.ui.settings
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
|
||||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
|
||||||
import androidx.glance.appwidget.updateAll
|
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
@@ -42,20 +39,12 @@ import de.jeanlucmakiola.calendula.domain.EventFormField
|
|||||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
|
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
|
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
|
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
|
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SHOW_TODAY_STATE_KEY
|
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SIZE_KEY
|
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
|
||||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
@@ -67,8 +56,6 @@ import kotlinx.coroutines.flow.catch
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -118,10 +105,8 @@ class SettingsViewModel @Inject constructor(
|
|||||||
dragToReschedule = this[SettingsKeys.dragToReschedule],
|
dragToReschedule = this[SettingsKeys.dragToReschedule],
|
||||||
tapOpensEditor = this[SettingsKeys.tapOpensEditor],
|
tapOpensEditor = this[SettingsKeys.tapOpensEditor],
|
||||||
agendaScreenRange = this[SettingsKeys.agendaScreenRange],
|
agendaScreenRange = this[SettingsKeys.agendaScreenRange],
|
||||||
agendaWidgetRange = this[SettingsKeys.agendaWidgetRange],
|
|
||||||
agendaShowToday = this[SettingsKeys.agendaShowToday],
|
agendaShowToday = this[SettingsKeys.agendaShowToday],
|
||||||
agendaShowRangeBar = this[SettingsKeys.agendaShowRangeBar],
|
agendaShowRangeBar = this[SettingsKeys.agendaShowRangeBar],
|
||||||
widgetSize = this[SettingsKeys.widgetSize],
|
|
||||||
defaultView = this[SettingsKeys.defaultView],
|
defaultView = this[SettingsKeys.defaultView],
|
||||||
quickSwitchConfig = this[SettingsKeys.quickSwitchConfig],
|
quickSwitchConfig = this[SettingsKeys.quickSwitchConfig],
|
||||||
monthViewStyle = this[SettingsKeys.monthViewStyle],
|
monthViewStyle = this[SettingsKeys.monthViewStyle],
|
||||||
@@ -181,10 +166,6 @@ class SettingsViewModel @Inject constructor(
|
|||||||
private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||||
val fontImportFailed: SharedFlow<Unit> = _fontImportFailed
|
val fontImportFailed: SharedFlow<Unit> = _fontImportFailed
|
||||||
|
|
||||||
// Serialises widget redraws: concurrent updateAll calls coalesce around a
|
|
||||||
// stale read and can leave the widget on an intermediate value.
|
|
||||||
private val widgetRefreshMutex = Mutex()
|
|
||||||
|
|
||||||
/** Contact special-dates sub-page (issue #15). */
|
/** Contact special-dates sub-page (issue #15). */
|
||||||
val specialDatesState: StateFlow<SpecialDatesUiState> = prefs.data.map { stored ->
|
val specialDatesState: StateFlow<SpecialDatesUiState> = prefs.data.map { stored ->
|
||||||
val templates = stored.specialDatesTitleTemplates
|
val templates = stored.specialDatesTitleTemplates
|
||||||
@@ -267,16 +248,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun setDynamicColor(enabled: Boolean) = save { setDynamicColor(enabled) }
|
fun setDynamicColor(enabled: Boolean) = save { setDynamicColor(enabled) }
|
||||||
|
|
||||||
fun setSoftenColors(enabled: Boolean) {
|
fun setSoftenColors(enabled: Boolean) = save { setSoftenCalendarColors(enabled) }
|
||||||
viewModelScope.launch {
|
|
||||||
prefs.setSoftenCalendarColors(enabled)
|
|
||||||
// Both widgets paint event colours through the same softener.
|
|
||||||
widgetRefreshMutex.withLock {
|
|
||||||
AgendaWidget().updateAll(appContext)
|
|
||||||
MonthWidget().updateAll(appContext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setFont(role: FontRole, token: String) = save {
|
fun setFont(role: FontRole, token: String) = save {
|
||||||
when (role) {
|
when (role) {
|
||||||
@@ -304,68 +276,13 @@ class SettingsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setWeekStart(pref: WeekStartPref) {
|
fun setWeekStart(pref: WeekStartPref) = save { setWeekStart(pref) }
|
||||||
viewModelScope.launch {
|
|
||||||
prefs.setWeekStart(pref)
|
|
||||||
// Both widgets depend on the week start (month weekday header; the
|
|
||||||
// agenda widget's "this week" range).
|
|
||||||
widgetRefreshMutex.withLock {
|
|
||||||
AgendaWidget().updateAll(appContext)
|
|
||||||
MonthWidget().updateAll(appContext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setAgendaScreenRange(range: AgendaRange) = save { setAgendaScreenRange(range) }
|
fun setAgendaScreenRange(range: AgendaRange) = save { setAgendaScreenRange(range) }
|
||||||
|
|
||||||
fun setAgendaWidgetRange(range: AgendaRange) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
prefs.setAgendaWidgetRange(range)
|
|
||||||
widgetRefreshMutex.withLock {
|
|
||||||
// Push into each instance's Glance state and recompose:
|
|
||||||
// updateAll alone won't re-run provideGlance's data preamble.
|
|
||||||
val manager = GlanceAppWidgetManager(appContext)
|
|
||||||
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
|
||||||
updateAppWidgetState(appContext, id) { it[AGENDA_RANGE_KEY] = range.storageValue() }
|
|
||||||
}
|
|
||||||
AgendaWidget().updateAll(appContext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setAgendaShowRangeBar(enabled: Boolean) = save { setAgendaShowRangeBar(enabled) }
|
fun setAgendaShowRangeBar(enabled: Boolean) = save { setAgendaShowRangeBar(enabled) }
|
||||||
|
|
||||||
/**
|
fun setAgendaShowToday(enabled: Boolean) = save { setAgendaShowToday(enabled) }
|
||||||
* Set the size the agenda widget draws its text at (#51), via the same
|
|
||||||
* Glance-state path as [setAgendaWidgetRange]. Agenda-only: the month widget
|
|
||||||
* sizes itself from the space it is given (#103).
|
|
||||||
*/
|
|
||||||
fun setWidgetSize(size: WidgetSize) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
prefs.setWidgetSize(size)
|
|
||||||
widgetRefreshMutex.withLock {
|
|
||||||
val manager = GlanceAppWidgetManager(appContext)
|
|
||||||
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
|
||||||
updateAppWidgetState(appContext, id) { it[AGENDA_SIZE_KEY] = size.name }
|
|
||||||
}
|
|
||||||
AgendaWidget().updateAll(appContext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setAgendaShowToday(enabled: Boolean) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
prefs.setAgendaShowToday(enabled)
|
|
||||||
// Same Glance-state path as setPastEventDisplay.
|
|
||||||
widgetRefreshMutex.withLock {
|
|
||||||
val manager = GlanceAppWidgetManager(appContext)
|
|
||||||
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
|
||||||
updateAppWidgetState(appContext, id) { it[AGENDA_SHOW_TODAY_STATE_KEY] = enabled }
|
|
||||||
}
|
|
||||||
AgendaWidget().updateAll(appContext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setTimeFormat(pref: TimeFormatPref) = save { setTimeFormat(pref) }
|
fun setTimeFormat(pref: TimeFormatPref) = save { setTimeFormat(pref) }
|
||||||
|
|
||||||
@@ -396,19 +313,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setPastEventDisplay(mode: PastEventDisplay) {
|
fun setPastEventDisplay(mode: PastEventDisplay) = save { setPastEventDisplay(mode) }
|
||||||
viewModelScope.launch {
|
|
||||||
prefs.setPastEventDisplay(mode)
|
|
||||||
// Same Glance-state path as setAgendaWidgetRange.
|
|
||||||
widgetRefreshMutex.withLock {
|
|
||||||
val manager = GlanceAppWidgetManager(appContext)
|
|
||||||
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
|
||||||
updateAppWidgetState(appContext, id) { it[AGENDA_PAST_DISPLAY_KEY] = mode.name }
|
|
||||||
}
|
|
||||||
AgendaWidget().updateAll(appContext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setDimCompletedEvents(enabled: Boolean) = save { setDimCompletedEvents(enabled) }
|
fun setDimCompletedEvents(enabled: Boolean) = save { setDimCompletedEvents(enabled) }
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -18,56 +20,46 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
|
||||||
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
||||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
|
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.floret.components.GroupedHint
|
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||||
import kotlin.math.roundToInt
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.calendula.widget.config.PlacedWidget
|
||||||
|
import de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import de.jeanlucmakiola.calendula.widget.config.backgroundLabel
|
||||||
|
import de.jeanlucmakiola.calendula.widget.config.placedWidgets
|
||||||
|
import de.jeanlucmakiola.calendula.widget.config.themeLabel
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Widgets & tiles (#69): the home-screen widgets' settings and the Quick
|
* Widgets & tiles (#69): the placed home-screen widgets, each opening its own
|
||||||
* Settings tile shortcut. The agenda widget keeps its own range and text size,
|
* config screen (#292), and the Quick Settings tile shortcut.
|
||||||
* separate from the Agenda screen's range in [ViewsScreen].
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun WidgetsScreen(
|
internal fun WidgetsScreen(
|
||||||
state: SettingsUiState,
|
|
||||||
viewModel: SettingsViewModel,
|
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
visible: Boolean = true,
|
visible: Boolean = true,
|
||||||
) {
|
) {
|
||||||
var showAgendaWidgetRange by remember { mutableStateOf(false) }
|
|
||||||
var showWidgetSize by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_widgets),
|
title = stringResource(R.string.settings_section_widgets),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
predictiveBack = true,
|
predictiveBack = true,
|
||||||
visible = visible,
|
visible = visible,
|
||||||
) {
|
) {
|
||||||
GroupedHint(stringResource(R.string.settings_widgets_hint))
|
GroupedHint(
|
||||||
Spacer(Modifier.height(8.dp))
|
stringResource(
|
||||||
|
// The launcher's "Edit widget" action only exists from Android 12.
|
||||||
GroupedRow(
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) R.string.settings_widgets_hint
|
||||||
title = stringResource(R.string.settings_agenda_widget_range),
|
else R.string.settings_widgets_hint_legacy,
|
||||||
summary = agendaRangeLabel(state.agendaWidgetRange),
|
),
|
||||||
position = Position.Top,
|
|
||||||
onClick = { showAgendaWidgetRange = true },
|
|
||||||
)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_widget_size),
|
|
||||||
summary = widgetSizeLabel(state.widgetSize),
|
|
||||||
position = Position.Bottom,
|
|
||||||
onClick = { showWidgetSize = true },
|
|
||||||
)
|
)
|
||||||
|
GroupedSectionHeader(stringResource(R.string.settings_widgets_placed))
|
||||||
|
PlacedWidgetRows()
|
||||||
|
|
||||||
// The add-tile prompt is API 33+; below that the tile is still
|
// The add-tile prompt is API 33+; below that the tile is still
|
||||||
// addable from the QS editor, so the row just doesn't appear.
|
// addable from the QS editor, so the row just doesn't appear.
|
||||||
@@ -76,30 +68,44 @@ internal fun WidgetsScreen(
|
|||||||
QuickSettingsTileRow()
|
QuickSettingsTileRow()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showAgendaWidgetRange) {
|
|
||||||
AgendaRangePicker(
|
|
||||||
title = stringResource(R.string.settings_agenda_widget_range),
|
|
||||||
description = stringResource(R.string.settings_agenda_widget_range_hint),
|
|
||||||
selected = state.agendaWidgetRange,
|
|
||||||
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
|
|
||||||
onSelect = viewModel::setAgendaWidgetRange,
|
|
||||||
onDismiss = { showAgendaWidgetRange = false },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (showWidgetSize) {
|
|
||||||
OptionPicker(
|
@Composable
|
||||||
title = stringResource(R.string.settings_widget_size),
|
private fun PlacedWidgetRows() {
|
||||||
header = { GroupedHint(stringResource(R.string.settings_widget_size_hint), verticalPadding = 8.dp) },
|
val context = LocalContext.current
|
||||||
predictiveBack = true,
|
// Read on every resume, the first included, so a widget added, removed or
|
||||||
options = WidgetSize.entries,
|
// edited meanwhile shows up.
|
||||||
selected = state.widgetSize,
|
var refresh by remember { mutableIntStateOf(0) }
|
||||||
label = { widgetSizeLabel(it) },
|
LifecycleResumeEffect(Unit) {
|
||||||
// No live preview: the widget is Glance/RemoteViews, so a Compose
|
refresh++
|
||||||
// mock-up would be a second implementation free to drift.
|
onPauseOrDispose {}
|
||||||
summary = { widgetSizeSummary(it) },
|
}
|
||||||
onSelect = viewModel::setWidgetSize,
|
val widgets by produceState<List<PlacedWidget>?>(initialValue = null, refresh) {
|
||||||
onDismiss = { showWidgetSize = false },
|
if (refresh > 0) value = withContext(Dispatchers.IO) { context.placedWidgets() }
|
||||||
|
}
|
||||||
|
val placed = widgets ?: return
|
||||||
|
if (placed.isEmpty()) {
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_widgets_none),
|
||||||
|
position = Position.Alone,
|
||||||
|
dimmed = true,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val kindCounts = placed.groupingBy { it.kind }.eachCount()
|
||||||
|
val kindSeen = mutableMapOf<WidgetKind, Int>()
|
||||||
|
placed.forEachIndexed { index, widget ->
|
||||||
|
val number = kindSeen.merge(widget.kind, 1, Int::plus)!!
|
||||||
|
val label = stringResource(widget.kind.label)
|
||||||
|
GroupedRow(
|
||||||
|
title = if (kindCounts.getValue(widget.kind) > 1) stringResource(R.string.widget_numbered, label, number) else label,
|
||||||
|
summary = stringResource(
|
||||||
|
R.string.widget_appearance_summary,
|
||||||
|
backgroundLabel(widget.appearance.background),
|
||||||
|
themeLabel(widget.appearance.theme),
|
||||||
|
),
|
||||||
|
position = positionOf(index, placed.size),
|
||||||
|
onClick = { context.startActivity(WidgetConfigActivity.intent(context, widget.appWidgetId)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,24 +137,3 @@ private fun requestAddQsTile(context: Context) {
|
|||||||
context.mainExecutor,
|
context.mainExecutor,
|
||||||
) { /* result code unused — the system surfaces its own feedback */ }
|
) { /* result code unused — the system surfaces its own feedback */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* What a size step does, as a percentage of the smallest one's event text.
|
|
||||||
* Derived from the widget's own [metricsFor] table rather than hardcoded.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun widgetSizeSummary(size: WidgetSize): String {
|
|
||||||
val base = metricsFor(WidgetSize.SMALL).eventTitle.value
|
|
||||||
val percent = (metricsFor(size).eventTitle.value / base * 100f).roundToInt()
|
|
||||||
return stringResource(R.string.settings_widget_size_summary, percent)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun widgetSizeLabel(size: WidgetSize): String = stringResource(
|
|
||||||
when (size) {
|
|
||||||
WidgetSize.SMALL -> R.string.settings_widget_size_small
|
|
||||||
WidgetSize.MEDIUM -> R.string.settings_widget_size_medium
|
|
||||||
WidgetSize.LARGE -> R.string.settings_widget_size_large
|
|
||||||
WidgetSize.EXTRA_LARGE -> R.string.settings_widget_size_extra_large
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.preferences.core.MutablePreferences
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||||
|
import androidx.glance.GlanceId
|
||||||
|
import androidx.glance.appwidget.state.getAppWidgetState
|
||||||
|
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||||
|
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.parseAgendaRange
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
|
||||||
|
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||||
|
import de.jeanlucmakiola.floret.prefs.toEnum
|
||||||
|
|
||||||
|
/** A widget's own light/dark choice (#292); [FOLLOW_APP] tracks the app's theme setting. */
|
||||||
|
enum class WidgetTheme { FOLLOW_APP, LIGHT, DARK }
|
||||||
|
|
||||||
|
/** How a widget looks, set per placed widget in its config screen (#292). */
|
||||||
|
data class WidgetAppearance(
|
||||||
|
val background: WidgetBackground = WidgetBackground.TINTED,
|
||||||
|
val theme: WidgetTheme = WidgetTheme.FOLLOW_APP,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** What the agenda widget lists, per placed widget. */
|
||||||
|
data class AgendaWidgetOptions(
|
||||||
|
val range: AgendaRange,
|
||||||
|
val textSize: WidgetSize,
|
||||||
|
val showToday: Boolean,
|
||||||
|
val pastDisplay: PastEventDisplay,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun WidgetTheme.resolve(app: ThemeMode): WidgetThemeMode = when (this) {
|
||||||
|
WidgetTheme.LIGHT -> WidgetThemeMode.LIGHT
|
||||||
|
WidgetTheme.DARK -> WidgetThemeMode.DARK
|
||||||
|
WidgetTheme.FOLLOW_APP -> when (app) {
|
||||||
|
ThemeMode.SYSTEM -> WidgetThemeMode.SYSTEM
|
||||||
|
ThemeMode.LIGHT -> WidgetThemeMode.LIGHT
|
||||||
|
ThemeMode.DARK, ThemeMode.BLACK -> WidgetThemeMode.DARK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val BackgroundKey = stringPreferencesKey("cfg_background")
|
||||||
|
private val ThemeKey = stringPreferencesKey("cfg_theme")
|
||||||
|
private val AgendaRangeKey = stringPreferencesKey("cfg_agenda_range")
|
||||||
|
private val TextSizeKey = stringPreferencesKey("cfg_text_size")
|
||||||
|
private val ShowTodayKey = booleanPreferencesKey("cfg_show_today")
|
||||||
|
private val PastDisplayKey = stringPreferencesKey("cfg_past_display")
|
||||||
|
private val HiddenCalendarsKey = stringSetPreferencesKey("cfg_hidden_calendars")
|
||||||
|
|
||||||
|
internal val Preferences.widgetAppearance: WidgetAppearance
|
||||||
|
get() = WidgetAppearance(
|
||||||
|
background = this[BackgroundKey].toEnum(WidgetBackground.TINTED),
|
||||||
|
theme = this[ThemeKey].toEnum(WidgetTheme.FOLLOW_APP),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This widget's agenda options. A widget placed before per-widget options
|
||||||
|
* existed has none stored, and keeps following the app-wide values in
|
||||||
|
* [defaults] until it is configured.
|
||||||
|
*/
|
||||||
|
internal fun Preferences.agendaWidgetOptions(defaults: AgendaWidgetOptions) = AgendaWidgetOptions(
|
||||||
|
range = parseAgendaRange(this[AgendaRangeKey], defaults.range),
|
||||||
|
textSize = this[TextSizeKey].toEnum(defaults.textSize),
|
||||||
|
showToday = this[ShowTodayKey] ?: defaults.showToday,
|
||||||
|
pastDisplay = this[PastDisplayKey].toEnum(defaults.pastDisplay),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Whether this widget has agenda options of its own, rather than following the app's. */
|
||||||
|
internal val Preferences.hasAgendaOptions: Boolean get() = this[AgendaRangeKey] != null
|
||||||
|
|
||||||
|
internal fun MutablePreferences.putAppearance(appearance: WidgetAppearance) {
|
||||||
|
this[BackgroundKey] = appearance.background.name
|
||||||
|
this[ThemeKey] = appearance.theme.name
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun MutablePreferences.putAgendaOptions(options: AgendaWidgetOptions) {
|
||||||
|
this[AgendaRangeKey] = options.range.storageValue()
|
||||||
|
this[TextSizeKey] = options.textSize.name
|
||||||
|
this[ShowTodayKey] = options.showToday
|
||||||
|
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 =
|
||||||
|
getAppWidgetState(this, PreferencesGlanceStateDefinition, id)
|
||||||
@@ -4,7 +4,12 @@ import android.Manifest
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.glance.GlanceId
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
@@ -13,7 +18,12 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
|||||||
import de.jeanlucmakiola.calendula.ui.agenda.agendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.agendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
|
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
|
||||||
|
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||||
|
import de.jeanlucmakiola.floret.prefs.get
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@@ -25,6 +35,7 @@ import kotlinx.datetime.plus
|
|||||||
import kotlinx.datetime.toInstant
|
import kotlinx.datetime.toInstant
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@@ -34,10 +45,63 @@ import kotlin.time.Instant
|
|||||||
* the whole window, and on slow devices a wide one held the widget on "loading"
|
* the whole window, and on slow devices a wide one held the widget on "loading"
|
||||||
* (#363).
|
* (#363).
|
||||||
*/
|
*/
|
||||||
private const val MONTH_WIDGET_PAD_MONTHS = 3
|
internal const val MONTH_WIDGET_PAD_MONTHS = 3
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every app setting the widgets draw with: what the loads read, and what
|
||||||
|
* [WidgetRefreshCoordinator] watches. The agenda fields are only the defaults
|
||||||
|
* for a widget that has no options of its own yet.
|
||||||
|
*/
|
||||||
|
internal data class WidgetSettings(
|
||||||
|
val soften: Boolean,
|
||||||
|
val weekStart: WeekStartPref,
|
||||||
|
val timeFormat: TimeFormatPref,
|
||||||
|
val themeMode: ThemeMode,
|
||||||
|
val pastDisplay: PastEventDisplay,
|
||||||
|
val agendaRange: AgendaRange,
|
||||||
|
val widgetSize: WidgetSize,
|
||||||
|
val showToday: Boolean,
|
||||||
|
) {
|
||||||
|
/** What every widget draws with; the agenda defaults aside. */
|
||||||
|
val shared: SharedWidgetSettings get() = SharedWidgetSettings(soften, weekStart, timeFormat, themeMode)
|
||||||
|
|
||||||
|
val agendaDefaults: AgendaWidgetOptions
|
||||||
|
get() = AgendaWidgetOptions(agendaRange, widgetSize, showToday, pastDisplay)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The [WidgetSettings] a configured widget still takes from the app. */
|
||||||
|
internal data class SharedWidgetSettings(
|
||||||
|
val soften: Boolean,
|
||||||
|
val weekStart: WeekStartPref,
|
||||||
|
val timeFormat: TimeFormatPref,
|
||||||
|
val themeMode: ThemeMode,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A widget's loaded [content], with the app theme a [WidgetTheme.FOLLOW_APP] widget resolves against. */
|
||||||
|
internal data class Themed<out T>(val content: T, val appTheme: ThemeMode)
|
||||||
|
|
||||||
|
internal val SettingsPrefs.widgetSettings: Flow<WidgetSettings>
|
||||||
|
get() = data.map {
|
||||||
|
WidgetSettings(
|
||||||
|
soften = it[SettingsKeys.softenCalendarColors],
|
||||||
|
weekStart = it[SettingsKeys.weekStart],
|
||||||
|
timeFormat = it[SettingsKeys.timeFormat],
|
||||||
|
themeMode = it[SettingsKeys.themeMode],
|
||||||
|
pastDisplay = it[SettingsKeys.pastEventDisplay],
|
||||||
|
agendaRange = it[SettingsKeys.agendaWidgetRange],
|
||||||
|
widgetSize = it[SettingsKeys.widgetSize],
|
||||||
|
showToday = it[SettingsKeys.agendaShowToday],
|
||||||
|
)
|
||||||
|
}.distinctUntilChanged()
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -51,112 +115,74 @@ sealed interface AgendaWidgetData {
|
|||||||
data object NeedsPermission : AgendaWidgetData
|
data object NeedsPermission : AgendaWidgetData
|
||||||
data class Ready(
|
data class Ready(
|
||||||
val today: LocalDate,
|
val today: LocalDate,
|
||||||
/**
|
/** Days with events across the configured range, unfiltered; hiding finished ones happens in the composition. */
|
||||||
* Days with events across the **widest** selectable window. The displayed
|
|
||||||
* range is sliced from this in the composition (read reactively from
|
|
||||||
* Glance state), so a range change is pure recomposition rather than a
|
|
||||||
* flaky widget session restart — the same approach the month widget uses.
|
|
||||||
* Kept unfiltered: hiding/dimming finished events also happens reactively
|
|
||||||
* in the composition, against [now], for the same reliable-update reason.
|
|
||||||
*/
|
|
||||||
val days: List<AgendaDay>,
|
val days: List<AgendaDay>,
|
||||||
/** Resolved clock convention for event time labels (the time-format pref). */
|
/** Resolved clock convention for event time labels (the time-format pref). */
|
||||||
val is24Hour: Boolean,
|
val is24Hour: Boolean,
|
||||||
/** Whether event colours are softened to pastels, or shown raw (#36). */
|
/** Whether event colours are softened to pastels, or shown raw (#36). */
|
||||||
val soften: Boolean,
|
val soften: Boolean,
|
||||||
/** First day of the week, for the calendar-aligned "this week" range. */
|
val pastDisplay: PastEventDisplay,
|
||||||
val weekStart: DayOfWeek,
|
/** "Always show today" (#35): anchor today at the top even with no events left. */
|
||||||
/** Saved range pref — the fallback when an instance has no Glance state yet. */
|
val showToday: Boolean,
|
||||||
val savedRange: AgendaRange,
|
val textSize: WidgetSize,
|
||||||
/** Saved past-event display pref — the fallback before Glance state is set. */
|
|
||||||
val savedPastDisplay: PastEventDisplay,
|
|
||||||
/**
|
|
||||||
* Saved "always show today" pref (#35) — anchors today at the top with a
|
|
||||||
* placeholder even when it has no events left. The fallback read reactively
|
|
||||||
* from Glance state before an instance has its own state set.
|
|
||||||
*/
|
|
||||||
val savedShowToday: Boolean,
|
|
||||||
/** Saved widget size step (#103) — the fallback before Glance state is set. */
|
|
||||||
val savedWidgetSize: WidgetSize,
|
|
||||||
/** Last day [days] covers; a wider range picked later needs a reload. */
|
|
||||||
val loadedThrough: LocalDate,
|
|
||||||
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
|
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
|
||||||
val now: Instant,
|
val now: Instant,
|
||||||
) : AgendaWidgetData
|
) : AgendaWidgetData
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Source data for the month widget: a wide window of instances plus the
|
* Source data for the month widget: a window of instances plus the week-start
|
||||||
* week-start preference and today. The widget computes each displayed month's
|
* preference and today. The widget computes each displayed month's grid from
|
||||||
* grid from this in-memory list (via `layoutMonthWeeks`) as the user pages,
|
* this in-memory list (via `layoutMonthWeeks`) as the user pages, so paging
|
||||||
* so month navigation is pure recomposition — no reload, no flaky widget
|
* inside the loaded window is pure recomposition.
|
||||||
* session restart.
|
|
||||||
*/
|
*/
|
||||||
sealed interface MonthWidgetSource {
|
sealed interface MonthWidgetSource {
|
||||||
data object NeedsPermission : MonthWidgetSource
|
data object NeedsPermission : MonthWidgetSource
|
||||||
data class Ready(
|
data class Ready(
|
||||||
val today: LocalDate,
|
val today: LocalDate,
|
||||||
val weekStart: DayOfWeek,
|
val weekStart: DayOfWeek,
|
||||||
|
val soften: Boolean,
|
||||||
val instances: List<EventInstance>,
|
val instances: List<EventInstance>,
|
||||||
/** Month indices (`year * 12 + monthOrdinal`) whose grids [instances] covers. */
|
/** Month indices (`year * 12 + monthOrdinal`) whose grids [instances] covers. */
|
||||||
val months: IntRange,
|
val months: IntRange,
|
||||||
) : MonthWidgetSource
|
) : MonthWidgetSource
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** [loadAgendaWidgetData] with agenda widget [id]'s own options. */
|
||||||
* Process-lived cache of the month window. Month navigation can re-run
|
internal suspend fun Context.loadAgendaWidget(id: GlanceId): Themed<AgendaWidgetData> {
|
||||||
* `provideGlance`, and re-querying on every arrow tap is what made paging feel
|
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
|
||||||
* sluggish — so we load once and reuse the same snapshot for every nearby month. Invalidated by
|
val state = widgetState(id)
|
||||||
* [invalidateMonthWidgetCache] when calendar data changes (the freshness
|
val options = state.agendaWidgetOptions(settings.agendaDefaults)
|
||||||
* receiver), and automatically when the day rolls over (the `today` guard).
|
return Themed(loadAgendaWidgetData(settings, options, state.hiddenCalendars), settings.themeMode)
|
||||||
*/
|
|
||||||
internal object MonthWidgetCache {
|
|
||||||
@Volatile
|
|
||||||
var data: MonthWidgetSource.Ready? = null
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun invalidateMonthWidgetCache() {
|
|
||||||
MonthWidgetCache.data = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One-shot read of the upcoming agenda for the widget. Reuses the app's
|
* One-shot read of the upcoming agenda for the widget. Reuses the app's
|
||||||
* [agendaRange] window and [groupAgendaDays] grouping, and the repository's
|
* [agendaRange] window and [groupAgendaDays] grouping, and the repository's
|
||||||
* [first]-emitted snapshot already has hidden calendars filtered out.
|
* [first]-emitted snapshot already has hidden calendars filtered out.
|
||||||
*
|
|
||||||
* Loads [range] (the widget's own, or the saved pref when null) rather than the
|
|
||||||
* widest selectable window (#363).
|
|
||||||
*/
|
*/
|
||||||
internal suspend fun Context.loadAgendaWidgetData(range: AgendaRange? = null): AgendaWidgetData {
|
internal suspend fun Context.loadAgendaWidgetData(
|
||||||
|
settings: WidgetSettings,
|
||||||
|
options: AgendaWidgetOptions,
|
||||||
|
hiddenCalendars: Set<Long>,
|
||||||
|
): AgendaWidgetData {
|
||||||
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
|
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
|
||||||
val zone = systemZone()
|
val zone = systemZone()
|
||||||
val anchor = today(zone)
|
val anchor = today(zone)
|
||||||
val ep = widgetEntryPoint()
|
val ep = widgetEntryPoint()
|
||||||
val prefs = ep.settingsPrefs()
|
val weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault())
|
||||||
val savedRange = prefs.agendaWidgetRange.first()
|
val dayCount = options.range.dayCount(anchor, weekStart)
|
||||||
val savedPastDisplay = prefs.pastEventDisplay.first()
|
|
||||||
val showToday = prefs.agendaShowToday.first()
|
|
||||||
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
|
|
||||||
// The displayed range is still sliced in the composition from Glance state;
|
|
||||||
// a wider one picked later reloads there (see loadedThrough).
|
|
||||||
val dayCount = (range ?: savedRange).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)
|
||||||
val is24Hour = prefs.timeFormat.first()
|
|
||||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
|
|
||||||
val soften = prefs.softenCalendarColors.first()
|
|
||||||
return AgendaWidgetData.Ready(
|
return AgendaWidgetData.Ready(
|
||||||
today = anchor,
|
today = anchor,
|
||||||
days = groupAgendaDays(anchor, windowEnd, instances, zone),
|
days = groupAgendaDays(anchor, windowEnd, instances, zone),
|
||||||
is24Hour = is24Hour,
|
is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)),
|
||||||
soften = soften,
|
soften = settings.soften,
|
||||||
weekStart = weekStart,
|
pastDisplay = options.pastDisplay,
|
||||||
savedRange = savedRange,
|
showToday = options.showToday,
|
||||||
savedPastDisplay = savedPastDisplay,
|
textSize = options.textSize,
|
||||||
savedShowToday = showToday,
|
|
||||||
savedWidgetSize = prefs.widgetSize.first(),
|
|
||||||
loadedThrough = windowEnd,
|
|
||||||
now = Clock.System.now(),
|
now = Clock.System.now(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -165,17 +191,55 @@ internal fun monthIndexOf(date: LocalDate): Int = date.year * 12 + date.month.or
|
|||||||
|
|
||||||
private fun firstDayOfMonthIndex(index: Int): LocalDate = LocalDate(index / 12, index % 12 + 1, 1)
|
private fun firstDayOfMonthIndex(index: Int): LocalDate = LocalDate(index / 12, index % 12 + 1, 1)
|
||||||
|
|
||||||
/** Read backing the month widget's grid for [centerIndex] and the months around it. */
|
/**
|
||||||
internal suspend fun Context.loadMonthWidgetSource(centerIndex: Int? = null): MonthWidgetSource {
|
* The month the widget loads its window around while [index] is shown. Snapped
|
||||||
|
* to steps of three, so paging only reloads every third month, and the window
|
||||||
|
* always reaches at least two months past [index] either way.
|
||||||
|
*/
|
||||||
|
internal fun monthWindowCenter(index: Int): Int = Math.floorDiv(index, 3) * 3 + 1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last window each month widget loaded, under its refresh stamp, so a new
|
||||||
|
* session for the same widget (a tap after the last one timed out) draws
|
||||||
|
* without a query. Every redraw moves the stamp, so an entry is never served
|
||||||
|
* stale.
|
||||||
|
*/
|
||||||
|
private object MonthSourceCache {
|
||||||
|
private class Entry(val center: Int, val stamp: Long, val source: Themed<MonthWidgetSource.Ready>)
|
||||||
|
|
||||||
|
private val entries = ConcurrentHashMap<GlanceId, Entry>()
|
||||||
|
|
||||||
|
fun get(id: GlanceId, center: Int, stamp: Long): Themed<MonthWidgetSource>? =
|
||||||
|
entries[id]?.takeIf { it.center == center && it.stamp == stamp && it.source.content.today == today(systemZone()) }
|
||||||
|
?.source
|
||||||
|
|
||||||
|
fun put(id: GlanceId, center: Int, stamp: Long, source: Themed<MonthWidgetSource>) {
|
||||||
|
val ready = source.content as? MonthWidgetSource.Ready ?: return
|
||||||
|
entries[id] = Entry(center, stamp, Themed(ready, source.appTheme))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [loadThemedMonthSource] for widget [id], served from [MonthSourceCache] while its [stamp] holds. */
|
||||||
|
internal suspend fun Context.loadCachedMonthSource(id: GlanceId, centerIndex: Int, stamp: Long): Themed<MonthWidgetSource> =
|
||||||
|
MonthSourceCache.get(id, centerIndex, stamp)
|
||||||
|
?: 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. */
|
||||||
|
internal suspend fun Context.loadThemedMonthSource(centerIndex: Int, hiddenCalendars: Set<Long>): Themed<MonthWidgetSource> {
|
||||||
|
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
|
||||||
|
return Themed(queryMonthSource(centerIndex, settings, hiddenCalendars), settings.themeMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 anchor = today(zone)
|
|
||||||
val center = centerIndex ?: monthIndexOf(anchor)
|
|
||||||
// Reuse the cached window unless the day changed (then it's stale for "today").
|
|
||||||
MonthWidgetCache.data?.let { if (it.today == anchor && center in it.months) return it }
|
|
||||||
val ep = widgetEntryPoint()
|
val ep = widgetEntryPoint()
|
||||||
val weekStart = ep.settingsPrefs().weekStart.first().resolveFirstDay(Locale.getDefault())
|
val months = (centerIndex - MONTH_WIDGET_PAD_MONTHS)..(centerIndex + MONTH_WIDGET_PAD_MONTHS)
|
||||||
val months = (center - MONTH_WIDGET_PAD_MONTHS)..(center + MONTH_WIDGET_PAD_MONTHS)
|
|
||||||
// Padded by the grid's spill into the neighbouring months.
|
// Padded by the grid's spill into the neighbouring months.
|
||||||
val from = firstDayOfMonthIndex(months.first)
|
val from = firstDayOfMonthIndex(months.first)
|
||||||
.minus(1, DateTimeUnit.WEEK)
|
.minus(1, DateTimeUnit.WEEK)
|
||||||
@@ -184,11 +248,79 @@ internal suspend fun Context.loadMonthWidgetSource(centerIndex: Int? = null): Mo
|
|||||||
.plus(2, DateTimeUnit.WEEK)
|
.plus(2, DateTimeUnit.WEEK)
|
||||||
.atTime(23, 59, 59)
|
.atTime(23, 59, 59)
|
||||||
.toInstant(zone)
|
.toInstant(zone)
|
||||||
val instances = ep.calendarRepository().instances(from..to).first()
|
|
||||||
return MonthWidgetSource.Ready(
|
return MonthWidgetSource.Ready(
|
||||||
today = anchor,
|
today = today(zone),
|
||||||
weekStart = weekStart,
|
weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()),
|
||||||
instances = instances,
|
soften = settings.soften,
|
||||||
|
instances = ep.calendarRepository().instances(from..to).first().withoutCalendars(hiddenCalendars),
|
||||||
months = months,
|
months = months,
|
||||||
).also { MonthWidgetCache.data = it }
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
|||||||
interface WidgetEntryPoint {
|
interface WidgetEntryPoint {
|
||||||
fun calendarRepository(): CalendarRepository
|
fun calendarRepository(): CalendarRepository
|
||||||
fun settingsPrefs(): SettingsPrefs
|
fun settingsPrefs(): SettingsPrefs
|
||||||
|
fun widgetRefreshCoordinator(): WidgetRefreshCoordinator
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun Context.widgetEntryPoint(): WidgetEntryPoint =
|
internal fun Context.widgetEntryPoint(): WidgetEntryPoint =
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.SystemClock
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
|
import de.jeanlucmakiola.calendula.data.di.ApplicationScope
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetRefresher
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetRollover
|
||||||
|
import de.jeanlucmakiola.floret.glance.updatePlaced
|
||||||
|
import de.jeanlucmakiola.floret.glance.updateWithRefresh
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.CoroutineStart
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** Every widget receiver; the set [WidgetRollover] checks for placed instances. */
|
||||||
|
internal val WIDGET_RECEIVER_CLASSES: List<Class<*>> = WidgetKind.entries.map { it.receiver }
|
||||||
|
|
||||||
|
/** The app's own midnight wake-up (#228). */
|
||||||
|
internal const val ACTION_WIDGET_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER"
|
||||||
|
|
||||||
|
/** Fixed: there is only ever one rollover alarm, and re-arming must replace it. */
|
||||||
|
private const val ROLLOVER_REQUEST_CODE = 0x0DA1
|
||||||
|
|
||||||
|
internal fun widgetRolloverPendingIntent(context: Context): PendingIntent = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
ROLLOVER_REQUEST_CODE,
|
||||||
|
Intent(context, WidgetUpdateReceiver::class.java).setAction(ACTION_WIDGET_ROLLOVER),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Arm the next midnight rollover, or cancel it when no widget is placed. Idempotent. */
|
||||||
|
internal fun syncWidgetRollover(context: Context) =
|
||||||
|
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reloads and redraws every placed widget, a running session included: the
|
||||||
|
* refresh stamp moves, and every widget keys its data load on it (#420).
|
||||||
|
*/
|
||||||
|
internal suspend fun redrawWidgets(context: Context) {
|
||||||
|
WidgetKind.entries.forEach { redrawWidgets(context, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal suspend fun redrawWidgets(context: Context, kind: WidgetKind) =
|
||||||
|
kind.widget().updatePlaced(context, kind.receiver, bumpRefreshStamp = true)
|
||||||
|
|
||||||
|
/** Redraws only the widgets with no content options of their own, which still follow the app's. */
|
||||||
|
private suspend fun redrawUnconfiguredWidgets(context: Context) {
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
val manager = AppWidgetManager.getInstance(appContext) ?: return
|
||||||
|
val glanceManager = GlanceAppWidgetManager(appContext)
|
||||||
|
WidgetKind.entries.filter { it.hasContentOptions }.forEach { kind ->
|
||||||
|
manager.getAppWidgetIds(ComponentName(appContext, kind.receiver)).forEach { appWidgetId ->
|
||||||
|
val glanceId = glanceManager.getGlanceIdBy(appWidgetId)
|
||||||
|
if (!appContext.widgetState(glanceId).hasAgendaOptions) kind.widget().updateWithRefresh(appContext, glanceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 const val PROVIDER_ECHO_WINDOW_MILLIS = 5_000L
|
||||||
|
|
||||||
|
internal fun isProviderEcho(): Boolean {
|
||||||
|
val last = lastLocalChange.get()
|
||||||
|
return last != 0L && SystemClock.elapsedRealtime() - last < PROVIDER_ECHO_WINDOW_MILLIS
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redraws the widgets on the app's own calendar writes, visibility changes and
|
||||||
|
* 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
|
||||||
|
* the app are [WidgetUpdateReceiver]'s. The app's agenda options only redraw
|
||||||
|
* widgets that have no content options of their own (#292). Started once from
|
||||||
|
* `CalendulaApp`.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class WidgetRefreshCoordinator @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val repository: CalendarRepository,
|
||||||
|
private val prefs: SettingsPrefs,
|
||||||
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
private val started = AtomicBoolean(false)
|
||||||
|
|
||||||
|
/** Subscribes before returning, so a change made right after is never taken for the starting value. */
|
||||||
|
fun start() {
|
||||||
|
if (!started.compareAndSet(false, true)) return
|
||||||
|
val all = WidgetKind.entries.map { kind -> WidgetRefresher(scope) { redrawWidgets(context, kind) } }
|
||||||
|
val unconfigured = WidgetRefresher(scope) { redrawUnconfiguredWidgets(context) }
|
||||||
|
watch(repository.localChanges) {
|
||||||
|
lastLocalChange.set(SystemClock.elapsedRealtime())
|
||||||
|
all.forEach { it.request() }
|
||||||
|
}
|
||||||
|
watch(prefs.widgetSettings.map { it.shared }.distinctUntilChanged().drop(1)) {
|
||||||
|
all.forEach { it.request() }
|
||||||
|
}
|
||||||
|
watch(prefs.widgetSettings.map { it.agendaDefaults }.distinctUntilChanged().drop(1)) {
|
||||||
|
unconfigured.request()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun watch(changes: Flow<*>, onChange: () -> Unit) {
|
||||||
|
scope.launch(Dispatchers.IO, start = CoroutineStart.UNDISPATCHED) {
|
||||||
|
changes.collect { onChange() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget
|
|
||||||
|
|
||||||
import android.app.AlarmManager
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.appwidget.AppWidgetManager
|
|
||||||
import android.content.ComponentName
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import androidx.core.content.getSystemService
|
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
|
|
||||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
|
|
||||||
import kotlinx.datetime.DateTimeUnit
|
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
|
||||||
import kotlinx.datetime.plus
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlin.time.Clock
|
|
||||||
import kotlin.time.Duration.Companion.hours
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Holds the app's own wake-up for the next local midnight, so the home-screen
|
|
||||||
* widgets roll "today" over on the day boundary (#228).
|
|
||||||
*
|
|
||||||
* The widgets used to lean on `ACTION_DATE_CHANGED`, which is not an exempted
|
|
||||||
* implicit broadcast — a manifest-declared receiver has not been given it since
|
|
||||||
* Android 8, leaving only the throttled `updatePeriodMillis`.
|
|
||||||
*
|
|
||||||
* Exactly one alarm exists at a time and every firing re-arms the next, the same
|
|
||||||
* shape as [de.jeanlucmakiola.calendula.data.reminders.ReminderAlarmScheduler].
|
|
||||||
* Deliberately **inexact**: `setAndAllowWhileIdle` needs no permission and
|
|
||||||
* survives doze (plain `set` does not), a rollover a few minutes late is
|
|
||||||
* invisible on a sleeping screen, and exact alarms stay reserved for snooze.
|
|
||||||
*/
|
|
||||||
object WidgetRolloverScheduler {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fire just *after* midnight: an alarm delivered a few milliseconds early
|
|
||||||
* would still read the old date and re-arm for an instant later.
|
|
||||||
*/
|
|
||||||
internal val ROLLOVER_SLACK = 5.seconds
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Arm the next rollover, or cancel a pending one when no widget is placed.
|
|
||||||
* Idempotent, so every trigger (boot, app start, widget added/removed, the
|
|
||||||
* rollover itself, a clock or timezone change) can just call it.
|
|
||||||
*/
|
|
||||||
fun sync(context: Context) {
|
|
||||||
val appContext = context.applicationContext
|
|
||||||
val alarmManager = appContext.getSystemService<AlarmManager>() ?: return
|
|
||||||
val pendingIntent = rolloverPendingIntent(appContext)
|
|
||||||
if (!hasPlacedWidgets(appContext)) {
|
|
||||||
alarmManager.cancel(pendingIntent)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val triggerAt = nextRolloverAt(Clock.System.now(), TimeZone.currentSystemDefault())
|
|
||||||
alarmManager.setAndAllowWhileIdle(
|
|
||||||
AlarmManager.RTC_WAKEUP, triggerAt.toEpochMilliseconds(), pendingIntent,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The instant just after the next local midnight following [now] in [zone].
|
|
||||||
*
|
|
||||||
* The *actual* start of day, not 00:00, so it holds where a DST jump means
|
|
||||||
* midnight never happens (Havana) and where a date-line move skips a whole
|
|
||||||
* local date (Apia, December 2011) — the loop walks on to the next real day.
|
|
||||||
*
|
|
||||||
* The mirror case — a zone rewinding *across* midnight, so the day starts
|
|
||||||
* twice — resolves to the earlier start and runs an hour ahead of the clock.
|
|
||||||
* No live tz entry does that (Brazil dropped DST in 2019) and
|
|
||||||
* `updatePeriodMillis` covers it, so it isn't worth state to detect.
|
|
||||||
*/
|
|
||||||
fun nextRolloverAt(now: Instant, zone: TimeZone): Instant {
|
|
||||||
val date = now.toLocalDateTime(zone).date
|
|
||||||
var days = 1
|
|
||||||
while (days <= MAX_LOOKAHEAD_DAYS) {
|
|
||||||
val candidate = date.plus(days, DateTimeUnit.DAY).atStartOfDayIn(zone) + ROLLOVER_SLACK
|
|
||||||
if (candidate > now) return candidate
|
|
||||||
days++
|
|
||||||
}
|
|
||||||
// Unreachable for any zone in the tz database. Deliberately an hour and
|
|
||||||
// not the slack: a 5-second retry would just hit this branch again and
|
|
||||||
// wake the device in a loop.
|
|
||||||
return now + 1.hours
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hasPlacedWidgets(context: Context): Boolean {
|
|
||||||
val manager = AppWidgetManager.getInstance(context) ?: return false
|
|
||||||
return PROVIDERS.any {
|
|
||||||
manager.getAppWidgetIds(ComponentName(context, it)).isNotEmpty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun rolloverPendingIntent(context: Context): PendingIntent =
|
|
||||||
PendingIntent.getBroadcast(
|
|
||||||
context,
|
|
||||||
ROLLOVER_REQUEST_CODE,
|
|
||||||
Intent(context, WidgetUpdateReceiver::class.java)
|
|
||||||
.setAction(WidgetUpdateReceiver.ACTION_ROLLOVER),
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
|
||||||
)
|
|
||||||
|
|
||||||
private val PROVIDERS = listOf(
|
|
||||||
MonthWidgetReceiver::class.java,
|
|
||||||
AgendaWidgetReceiver::class.java,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Fixed: there is only ever one rollover alarm, and re-arming must replace it. */
|
|
||||||
private const val ROLLOVER_REQUEST_CODE = 0x0DA1
|
|
||||||
|
|
||||||
/** A gap of more than a couple of days does not exist in any tz database entry. */
|
|
||||||
private const val MAX_LOOKAHEAD_DAYS = 3
|
|
||||||
}
|
|
||||||
@@ -1,39 +1,36 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.glance.GlanceTheme
|
|
||||||
import androidx.glance.material3.ColorProviders
|
|
||||||
import androidx.glance.text.TextDecoration
|
import androidx.glance.text.TextDecoration
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback
|
||||||
|
import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetSurface
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
|
||||||
|
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Brand fallback for devices without Material You dynamic colour (API < 31).
|
* Glance equivalent of `CalendulaTheme`: Material You on API 31+, the app's
|
||||||
* Reuses the exact same hand-tuned schemes as the in-app theme
|
* hand-tuned fallback schemes below that, in the light/dark [mode] the widget
|
||||||
* ([CalendulaLightFallback] / [CalendulaDarkFallback]) so a widget on an older
|
* was configured with (#292).
|
||||||
* device matches the app surface-for-surface.
|
|
||||||
*/
|
|
||||||
private val CalendulaGlanceColors = ColorProviders(
|
|
||||||
light = CalendulaLightFallback,
|
|
||||||
dark = CalendulaDarkFallback,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Glance equivalent of `CalendulaTheme`. On API 31+ it follows the system's
|
|
||||||
* Material You palette (so the widget matches the home screen / the app's
|
|
||||||
* dynamic colour); below that it falls back to the brand scheme. Either way the
|
|
||||||
* widget draws only from M3 colour-role tokens (`GlanceTheme.colors.*`) — never
|
|
||||||
* a hardcoded colour — so it tracks light/dark automatically.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendulaGlanceTheme(content: @Composable () -> Unit) {
|
fun CalendulaGlanceTheme(mode: WidgetThemeMode, content: @Composable () -> Unit) {
|
||||||
val colors = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
FloretGlanceTheme(
|
||||||
GlanceTheme.colors
|
lightScheme = CalendulaLightFallback,
|
||||||
} else {
|
darkScheme = CalendulaDarkFallback,
|
||||||
CalendulaGlanceColors
|
dynamicColor = true,
|
||||||
|
mode = mode,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A widget's root: its configured theme and background around [content]. */
|
||||||
|
@Composable
|
||||||
|
internal fun CalendulaWidgetRoot(appearance: WidgetAppearance, appTheme: ThemeMode, content: @Composable () -> Unit) {
|
||||||
|
CalendulaGlanceTheme(appearance.theme.resolve(appTheme)) {
|
||||||
|
WidgetSurface(background = appearance.background, content = content)
|
||||||
}
|
}
|
||||||
GlanceTheme(colors = colors, content = content)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,120 +1,29 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
import android.content.BroadcastReceiver
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.SystemClock
|
import de.jeanlucmakiola.floret.glance.FloretWidgetUpdateReceiver
|
||||||
import androidx.glance.appwidget.updateAll
|
import kotlin.time.Duration.Companion.minutes
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
|
||||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redraws both home-screen widgets when their data goes stale, and keeps the
|
* Redraws both widgets when their data, day or language goes stale, and keeps
|
||||||
* midnight rollover alarm armed. Triggered by:
|
* the midnight rollover armed. `PROVIDER_CHANGED` covers changes made outside
|
||||||
* - `PROVIDER_CHANGED` from the calendar provider — fires on any data change,
|
* the app; the app's own writes go through [WidgetRefreshCoordinator]. A data
|
||||||
* so it covers both the app's own writes and external sync.
|
* change also re-arms, at most every 15 minutes: the inexact alarm can be
|
||||||
* - [ACTION_ROLLOVER], the app's own alarm from [WidgetRolloverScheduler] —
|
* dropped in a restricted standby bucket, and a calendar change is the one
|
||||||
* the day boundary, so "today" highlighting and the agenda's past-event
|
* signal that keeps arriving. A `PROVIDER_CHANGED` right after one of the
|
||||||
* dimming move on (#228).
|
* app's own changes only echoes it, so it doesn't redraw a second time.
|
||||||
* - `TIME_SET` / `TIMEZONE_CHANGED` — the day boundary moved, so redraw *and*
|
|
||||||
* re-arm.
|
|
||||||
* - `BOOT_COMPLETED` / `MY_PACKAGE_REPLACED` — both wipe pending alarms; the
|
|
||||||
* latter is also what arms installs upgrading into the fix.
|
|
||||||
*
|
|
||||||
* `DATE_CHANGED` rides along as a free extra the system may or may not send —
|
|
||||||
* see [WidgetRolloverScheduler]. The backstops are `updatePeriodMillis` in the
|
|
||||||
* provider XML and the month widget's refresh button.
|
|
||||||
*
|
|
||||||
* Every action that dropped, consumed or invalidated the alarm re-arms it, and
|
|
||||||
* `PROVIDER_CHANGED` re-arms at most once every [DATA_REARM_INTERVAL_MILLIS]
|
|
||||||
* ([dataRearmDue]). The alarm is inexact, so the system may drop it outright in
|
|
||||||
* a restricted standby bucket; nothing in ordinary daily use would then notice,
|
|
||||||
* and the widget sits on yesterday's date. A calendar change is the one signal
|
|
||||||
* that keeps arriving — but it arrives on *every* write (each drag, each event
|
|
||||||
* of a thousand-event import), which is what the interval is for.
|
|
||||||
*
|
|
||||||
* Exported for the system broadcasts; an extra redraw from another app is
|
|
||||||
* harmless.
|
|
||||||
*/
|
*/
|
||||||
class WidgetUpdateReceiver : BroadcastReceiver() {
|
class WidgetUpdateReceiver : FloretWidgetUpdateReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override val rolloverAction = ACTION_WIDGET_ROLLOVER
|
||||||
// Exported, so anything can reach it with an explicit intent. Nothing
|
override val widgetReceiverClasses = WIDGET_RECEIVER_CLASSES
|
||||||
// here crosses a trust boundary, but narrowing to the actions we asked
|
override val extraHandledActions = setOf(Intent.ACTION_PROVIDER_CHANGED)
|
||||||
// for keeps a stray broadcast from costing two wide provider reads.
|
override val extraRearmInterval = 15.minutes
|
||||||
if (intent.action !in HANDLED_ACTIONS) return
|
|
||||||
val appContext = context.applicationContext
|
|
||||||
val rearm = intent.action in REARM_ACTIONS ||
|
|
||||||
(intent.action == Intent.ACTION_PROVIDER_CHANGED && dataRearmDue())
|
|
||||||
// The host sends APPWIDGET_UPDATE after the re-arm-only ones anyway, so
|
|
||||||
// redrawing there would only repeat the work in a cold process, at the
|
|
||||||
// moment the device is most contended.
|
|
||||||
val redraw = intent.action !in REARM_ONLY_ACTIONS
|
|
||||||
val pending = goAsync()
|
|
||||||
// Calendar data may have changed (sync / our own write) — drop the cached
|
|
||||||
// month window so the widgets reload fresh. Month paging does NOT call
|
|
||||||
// this, so arrow taps stay instant.
|
|
||||||
if (redraw) invalidateMonthWidgetCache()
|
|
||||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
|
||||||
try {
|
|
||||||
// Re-armed first, so the next day boundary is covered whatever
|
|
||||||
// the redraw does — and off the receiving thread, since it is a
|
|
||||||
// handful of binder calls (`CalendulaApp` moves it for the same
|
|
||||||
// reason).
|
|
||||||
if (rearm) WidgetRolloverScheduler.sync(appContext)
|
|
||||||
if (redraw) {
|
|
||||||
AgendaWidget().updateAll(appContext)
|
|
||||||
MonthWidget().updateAll(appContext)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
pending.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
override fun rolloverPendingIntent(context: Context): PendingIntent = widgetRolloverPendingIntent(context)
|
||||||
/** The app's own midnight wake-up; see [WidgetRolloverScheduler]. */
|
|
||||||
const val ACTION_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER"
|
|
||||||
|
|
||||||
/** Both wipe pending alarms, and the host redraws the widgets itself after them. */
|
override fun skipRedraw(action: String) = action == Intent.ACTION_PROVIDER_CHANGED && isProviderEcho()
|
||||||
private val REARM_ONLY_ACTIONS = setOf(
|
|
||||||
Intent.ACTION_BOOT_COMPLETED,
|
|
||||||
Intent.ACTION_MY_PACKAGE_REPLACED,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
override suspend fun redraw(context: Context) = redrawWidgets(context)
|
||||||
* The actions that leave no alarm standing: the two above wipe it, the
|
|
||||||
* rollover consumed itself, and a clock, zone or day change moved the
|
|
||||||
* boundary the pending one was set for.
|
|
||||||
*/
|
|
||||||
internal val REARM_ACTIONS = REARM_ONLY_ACTIONS + setOf(
|
|
||||||
ACTION_ROLLOVER,
|
|
||||||
Intent.ACTION_TIME_CHANGED,
|
|
||||||
Intent.ACTION_TIMEZONE_CHANGED,
|
|
||||||
Intent.ACTION_DATE_CHANGED,
|
|
||||||
)
|
|
||||||
|
|
||||||
internal val HANDLED_ACTIONS = REARM_ACTIONS + Intent.ACTION_PROVIDER_CHANGED
|
|
||||||
|
|
||||||
/** How rarely a calendar change may re-arm; see the class doc. */
|
|
||||||
private const val DATA_REARM_INTERVAL_MILLIS = 15 * 60_000L
|
|
||||||
|
|
||||||
/** Uptime of the last data-change re-arm, 0 for none in this process. */
|
|
||||||
private val lastDataRearmMillis = AtomicLong(0L)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether a calendar change may re-arm now — the backstop for an alarm
|
|
||||||
* the system dropped, throttled so a burst of writes costs one pass.
|
|
||||||
*/
|
|
||||||
private fun dataRearmDue(): Boolean {
|
|
||||||
val now = SystemClock.elapsedRealtime()
|
|
||||||
val last = lastDataRearmMillis.get()
|
|
||||||
val due = last == 0L || now - last >= DATA_REARM_INTERVAL_MILLIS
|
|
||||||
return due && lastDataRearmMillis.compareAndSet(last, now)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,17 +6,11 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||||
|
|
||||||
/**
|
/** Horizontal inset of a row's text inside its card; doesn't scale, so it never eats title width. */
|
||||||
* Horizontal layout constants for an agenda event row. These don't scale with the
|
internal val ROW_H_PAD = 12.dp
|
||||||
* size step, since a wider stripe or gap would eat title width, but [TEXT_INDENT]
|
|
||||||
* is derived from them so non-event text can't drift out of alignment.
|
|
||||||
*/
|
|
||||||
internal val ROW_H_PAD = 4.dp
|
|
||||||
internal val STRIPE_WIDTH = 5.dp
|
|
||||||
internal val STRIPE_GAP = 10.dp
|
|
||||||
|
|
||||||
/** Left indent that lines non-event text up with the event title column. */
|
/** Space between the date-pill gutter and a day's card. */
|
||||||
internal val TEXT_INDENT = ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP
|
internal val GUTTER_GAP = 6.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every size the agenda widget varies by [WidgetSize]. Values that genuinely
|
* Every size the agenda widget varies by [WidgetSize]. Values that genuinely
|
||||||
@@ -24,90 +18,91 @@ internal val TEXT_INDENT = ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP
|
|||||||
* constants rather than routing through here.
|
* constants rather than routing through here.
|
||||||
*/
|
*/
|
||||||
internal data class AgendaMetrics(
|
internal data class AgendaMetrics(
|
||||||
val title: TextUnit, // header "Upcoming"
|
val pillWeekday: TextUnit, // date pill weekday ("Thu")
|
||||||
val dayHeader: TextUnit, // day label ("Today · …")
|
val pillDay: TextUnit, // date pill day number
|
||||||
val eventTitle: TextUnit, // event title line
|
val eventTitle: TextUnit, // event title line
|
||||||
val eventTime: TextUnit, // time/location line
|
val eventTime: TextUnit, // time/location line
|
||||||
val placeholder: TextUnit, // "no more events today" (#35)
|
val placeholder: TextUnit, // "no more events today" (#35)
|
||||||
val message: TextUnit, // empty/permission centred message
|
val message: TextUnit, // empty/permission centred message
|
||||||
val stripeH: Dp, // coloured event stripe height
|
val contentH: Dp, // an event row's two text lines
|
||||||
val iconImage: Dp, // header action icon glyph
|
val pillWidth: Dp, // date pill / gutter width
|
||||||
val iconBox: Dp, // header action touch target
|
|
||||||
val rowVPad: Dp, // event row vertical padding
|
val rowVPad: Dp, // event row vertical padding
|
||||||
val dayHeaderTopPad: Dp, // space above a day header
|
val dayGap: Dp, // space between two days' cards
|
||||||
|
val monthLabel: TextUnit, // month name above a new month's first day
|
||||||
) {
|
) {
|
||||||
|
/** One event row's height, shared by the date pill beside it and the placeholder row. */
|
||||||
|
val rowHeight: Dp get() = contentH + rowVPad * 2
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the stripe height against the user's system font scale: the stripe
|
* Resolves the row content height and pill width against the user's system font
|
||||||
* is a [Dp] while the lines beside it are `sp`, so without this the text
|
* scale: both are [Dp] while the text in and beside them is `sp`, so without
|
||||||
* outgrows it at large font settings.
|
* this the text outgrows them at large font settings.
|
||||||
*/
|
*/
|
||||||
fun scaledForFont(fontScale: Float): AgendaMetrics =
|
fun scaledForFont(fontScale: Float): AgendaMetrics =
|
||||||
if (fontScale == 1f) this else copy(stripeH = stripeH * fontScale)
|
if (fontScale == 1f) this else copy(contentH = contentH * fontScale, pillWidth = pillWidth * fontScale)
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Type sizes are anchored to the Material 3 type scale, with two deviations:
|
* Type sizes are anchored to the Material 3 type scale, with one deviation:
|
||||||
*
|
*
|
||||||
* ‡ SMALL's 13sp day header is off-scale, held there so SMALL reproduces the
|
|
||||||
* widget's original constants verbatim (#51).
|
|
||||||
* † Above Title Medium the M3 scale jumps 16 → 22 → 24, too coarse for four
|
* † Above Title Medium the M3 scale jumps 16 → 22 → 24, too coarse for four
|
||||||
* steps; interpolated values are used where a role would force a ≥1.4x jump.
|
* steps; interpolated values are used where a role would force a ≥1.4x jump.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
private val SMALL_METRICS = AgendaMetrics(
|
private val SMALL_METRICS = AgendaMetrics(
|
||||||
title = 16.sp, // M3 Title Medium
|
pillWeekday = 11.sp,
|
||||||
dayHeader = 13.sp, // ‡ off-scale, holds the #51 baseline
|
pillDay = 16.sp,
|
||||||
eventTitle = 14.sp, // M3 Body Medium
|
eventTitle = 14.sp, // M3 Body Medium
|
||||||
eventTime = 12.sp, // M3 Body Small
|
eventTime = 12.sp, // M3 Body Small
|
||||||
placeholder = 14.sp, // M3 Body Medium
|
placeholder = 14.sp, // M3 Body Medium
|
||||||
message = 14.sp, // M3 Body Medium
|
message = 14.sp, // M3 Body Medium
|
||||||
stripeH = 36.dp,
|
contentH = 36.dp,
|
||||||
iconImage = 22.dp,
|
pillWidth = 40.dp,
|
||||||
iconBox = 40.dp,
|
|
||||||
rowVPad = 4.dp,
|
rowVPad = 4.dp,
|
||||||
dayHeaderTopPad = 10.dp,
|
dayGap = 8.dp,
|
||||||
|
monthLabel = 12.sp,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val MEDIUM_METRICS = AgendaMetrics(
|
private val MEDIUM_METRICS = AgendaMetrics(
|
||||||
title = 18.sp, // †
|
pillWeekday = 12.sp,
|
||||||
dayHeader = 14.sp, // M3 Title Small
|
pillDay = 18.sp,
|
||||||
eventTitle = 16.sp, // M3 Body Large
|
eventTitle = 16.sp, // M3 Body Large
|
||||||
eventTime = 14.sp, // M3 Body Medium
|
eventTime = 14.sp, // M3 Body Medium
|
||||||
placeholder = 16.sp, // M3 Body Large
|
placeholder = 16.sp, // M3 Body Large
|
||||||
message = 16.sp, // M3 Body Large
|
message = 16.sp, // M3 Body Large
|
||||||
stripeH = 40.dp,
|
contentH = 40.dp,
|
||||||
iconImage = 24.dp,
|
pillWidth = 44.dp,
|
||||||
iconBox = 44.dp,
|
|
||||||
rowVPad = 5.dp,
|
rowVPad = 5.dp,
|
||||||
dayHeaderTopPad = 11.dp,
|
dayGap = 9.dp,
|
||||||
|
monthLabel = 14.sp,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val LARGE_METRICS = AgendaMetrics(
|
private val LARGE_METRICS = AgendaMetrics(
|
||||||
title = 20.sp, // †
|
pillWeekday = 12.sp,
|
||||||
dayHeader = 16.sp, // M3 Title Medium
|
pillDay = 20.sp,
|
||||||
eventTitle = 18.sp, // †
|
eventTitle = 18.sp, // †
|
||||||
eventTime = 14.sp, // M3 Body Medium — the secondary line steps more slowly
|
eventTime = 14.sp, // M3 Body Medium — the secondary line steps more slowly
|
||||||
placeholder = 18.sp, // † on purpose, so the title keeps its
|
placeholder = 18.sp, // † on purpose, so the title keeps its
|
||||||
message = 18.sp, // † lead and the hierarchy survives.
|
message = 18.sp, // † lead and the hierarchy survives.
|
||||||
stripeH = 46.dp,
|
contentH = 46.dp,
|
||||||
iconImage = 26.dp,
|
pillWidth = 48.dp,
|
||||||
iconBox = 48.dp,
|
|
||||||
rowVPad = 6.dp,
|
rowVPad = 6.dp,
|
||||||
dayHeaderTopPad = 12.dp,
|
dayGap = 10.dp,
|
||||||
|
monthLabel = 14.sp,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val EXTRA_LARGE_METRICS = AgendaMetrics(
|
private val EXTRA_LARGE_METRICS = AgendaMetrics(
|
||||||
title = 22.sp, // M3 Title Large
|
pillWeekday = 14.sp,
|
||||||
dayHeader = 18.sp, // †
|
pillDay = 22.sp,
|
||||||
eventTitle = 20.sp, // †
|
eventTitle = 20.sp, // †
|
||||||
eventTime = 16.sp, // M3 Body Large
|
eventTime = 16.sp, // M3 Body Large
|
||||||
placeholder = 20.sp, // †
|
placeholder = 20.sp, // †
|
||||||
message = 20.sp, // †
|
message = 20.sp, // †
|
||||||
stripeH = 52.dp,
|
contentH = 52.dp,
|
||||||
iconImage = 28.dp,
|
pillWidth = 52.dp,
|
||||||
iconBox = 52.dp,
|
|
||||||
rowVPad = 8.dp,
|
rowVPad = 8.dp,
|
||||||
dayHeaderTopPad = 14.dp,
|
dayGap = 12.dp,
|
||||||
|
monthLabel = 16.sp,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Indexed by [WidgetSize.ordinal] so lookup allocates nothing per recomposition. */
|
/** Indexed by [WidgetSize.ordinal] so lookup allocates nothing per recomposition. */
|
||||||
|
|||||||
@@ -1,40 +1,27 @@
|
|||||||
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
|
||||||
import android.content.res.Configuration
|
|
||||||
import androidx.compose.runtime.Composable
|
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.setValue
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.datastore.preferences.core.Preferences
|
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
|
||||||
import androidx.glance.ColorFilter
|
|
||||||
import androidx.glance.GlanceId
|
import androidx.glance.GlanceId
|
||||||
import androidx.glance.GlanceModifier
|
import androidx.glance.GlanceModifier
|
||||||
import androidx.glance.GlanceTheme
|
import androidx.glance.GlanceTheme
|
||||||
import androidx.glance.Image
|
|
||||||
import androidx.glance.ImageProvider
|
import androidx.glance.ImageProvider
|
||||||
import androidx.glance.action.ActionParameters
|
|
||||||
import androidx.glance.action.clickable
|
import androidx.glance.action.clickable
|
||||||
import androidx.glance.appwidget.GlanceAppWidget
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
import androidx.glance.appwidget.SizeMode
|
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.action.actionStartActivity
|
||||||
import androidx.glance.appwidget.cornerRadius
|
import androidx.glance.appwidget.cornerRadius
|
||||||
import androidx.glance.appwidget.lazy.LazyColumn
|
import androidx.glance.appwidget.lazy.LazyColumn
|
||||||
import androidx.glance.appwidget.lazy.items
|
import androidx.glance.appwidget.lazy.items
|
||||||
import androidx.glance.appwidget.provideContent
|
import androidx.glance.appwidget.provideContent
|
||||||
import androidx.glance.background
|
import androidx.glance.background
|
||||||
import androidx.glance.currentState
|
|
||||||
import androidx.glance.appwidget.state.getAppWidgetState
|
|
||||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||||
import androidx.glance.layout.Alignment
|
import androidx.glance.layout.Alignment
|
||||||
import androidx.glance.layout.Box
|
import androidx.glance.layout.Box
|
||||||
@@ -47,78 +34,58 @@ 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.layout.width
|
||||||
|
import androidx.glance.semantics.contentDescription
|
||||||
|
import androidx.glance.semantics.semantics
|
||||||
|
import androidx.glance.text.TextAlign
|
||||||
|
import androidx.glance.unit.ColorProvider
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetHeader
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetHeaderAction
|
||||||
import androidx.glance.text.FontWeight
|
import androidx.glance.text.FontWeight
|
||||||
import androidx.glance.text.Text
|
import androidx.glance.text.Text
|
||||||
import androidx.glance.text.TextStyle
|
import androidx.glance.text.TextStyle
|
||||||
import de.jeanlucmakiola.calendula.MainActivity
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.parsePastEventDisplay
|
|
||||||
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.AgendaRange
|
|
||||||
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.agenda.dayCount
|
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
|
||||||
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.formatTimeOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
||||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||||
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
|
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||||
|
import de.jeanlucmakiola.calendula.widget.widgetCardColor
|
||||||
|
import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
|
||||||
|
import de.jeanlucmakiola.calendula.widget.loadAgendaWidget
|
||||||
|
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetGroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.glance.themedColor
|
||||||
|
import de.jeanlucmakiola.floret.glance.wallpaperBacking
|
||||||
|
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.glance.currentState
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetData
|
||||||
|
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
|
||||||
|
import de.jeanlucmakiola.floret.glance.refreshStamp
|
||||||
import de.jeanlucmakiola.calendula.widget.systemZone
|
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.toLocalDateTime
|
import kotlinx.datetime.toJavaDayOfWeek
|
||||||
import kotlin.time.Instant
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-instance Glance state key holding the agenda range (as [AgendaRange.storageValue]).
|
|
||||||
* The range is read reactively in the composition ([currentState]) so a settings
|
|
||||||
* change is reflected by plain recomposition — `updateAll` does NOT reliably
|
|
||||||
* re-run the `provideGlance` preamble for a live widget session, so loading the
|
|
||||||
* range there and slicing it would silently keep the stale value (mirrors the
|
|
||||||
* month widget's MONTH_INDEX_KEY approach).
|
|
||||||
*/
|
|
||||||
internal val AGENDA_RANGE_KEY = stringPreferencesKey("agenda_range")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-instance Glance state key holding the past-event display mode (as
|
|
||||||
* [PastEventDisplay.name]). Read reactively in the composition for the same
|
|
||||||
* reason as [AGENDA_RANGE_KEY] — so toggling the setting reflects on the live
|
|
||||||
* widget without depending on the `provideGlance` preamble re-running.
|
|
||||||
*/
|
|
||||||
internal val AGENDA_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-instance Glance state key holding the "always show today" toggle (#35).
|
|
||||||
* Read reactively in the composition for the same reason as [AGENDA_RANGE_KEY] —
|
|
||||||
* so toggling the setting reflects on the live widget without depending on the
|
|
||||||
* `provideGlance` preamble re-running.
|
|
||||||
*/
|
|
||||||
internal val AGENDA_SHOW_TODAY_STATE_KEY = booleanPreferencesKey("agenda_show_today")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-instance Glance state key holding the chosen [WidgetSize]. Read reactively
|
|
||||||
* in the composition for the same reason as [AGENDA_RANGE_KEY] — so changing the
|
|
||||||
* setting reflects on the live widget without depending on the `provideGlance`
|
|
||||||
* preamble re-running.
|
|
||||||
*/
|
|
||||||
internal val AGENDA_SIZE_KEY = stringPreferencesKey("widget_size")
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
|
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
|
||||||
* of events grouped under day headers (the Google "Schedule" widget model).
|
* of events, each day one card of grouped rows with its date pill beside it.
|
||||||
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
|
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
|
||||||
*/
|
*/
|
||||||
class AgendaWidget : GlanceAppWidget() {
|
class AgendaWidget : GlanceAppWidget() {
|
||||||
@@ -131,32 +98,47 @@ 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 stored = getAppWidgetState<Preferences>(context, id)[AGENDA_RANGE_KEY]
|
val stamp = refreshStamp(context, id)
|
||||||
val data = context.loadAgendaWidgetData(stored?.let { parseAgendaRange(it, AgendaRange.Month) })
|
val widgetData = WidgetData(context.loadAgendaWidget(id), stamp)
|
||||||
val dark = (context.resources.configuration.uiMode and
|
|
||||||
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
|
||||||
provideContent {
|
provideContent {
|
||||||
CalendulaGlanceTheme {
|
// A running session never re-runs this preamble, so every redraw
|
||||||
AgendaWidgetBody(data = data, dark = dark)
|
// moves the stamp and the data reloads here instead (#420).
|
||||||
}
|
val loaded = widgetData.current(currentRefreshStamp()) { context.loadAgendaWidget(id) }
|
||||||
|
AgendaWidgetContent(
|
||||||
|
data = loaded.content,
|
||||||
|
appearance = currentState<Preferences>().widgetAppearance,
|
||||||
|
appTheme = loaded.appTheme,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redraws the widget (header refresh button). Targets the tapped widget's own id
|
* The whole agenda widget, shared with its config screen's preview (#292). The
|
||||||
* rather than `updateAll`, whose provider-name lookup is empty in a process a tap
|
* [preview] draws its rows as a plain column, which RemoteViews can show
|
||||||
* woke from cold — see `ShiftMonthAction` (#18). A cold process re-reads the
|
* outside a widget host on every Android version.
|
||||||
* calendar in the `provideGlance` preamble; a live session only recomposes from
|
|
||||||
* the snapshot it already has (see [AGENDA_RANGE_KEY]).
|
|
||||||
*/
|
*/
|
||||||
class RefreshAgendaAction : ActionCallback {
|
@Composable
|
||||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
internal fun AgendaWidgetContent(
|
||||||
AgendaWidget().update(context.applicationContext, glanceId)
|
data: AgendaWidgetData,
|
||||||
|
appearance: WidgetAppearance,
|
||||||
|
appTheme: ThemeMode,
|
||||||
|
preview: Boolean = false,
|
||||||
|
) {
|
||||||
|
CalendulaWidgetRoot(appearance, appTheme) {
|
||||||
|
AgendaWidgetBody(data = data, preview = preview)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Rows a preview draws: Glance's cap on a Column's children, still more than a placed widget shows. */
|
||||||
|
private const val PREVIEW_ROWS = 10
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upper bound on rows handed to the [LazyColumn], so the serialized RemoteViews
|
* Upper bound on rows handed to the [LazyColumn], so the serialized RemoteViews
|
||||||
* stays well inside the binder transaction limit regardless of range and calendar
|
* stays well inside the binder transaction limit regardless of range and calendar
|
||||||
@@ -166,57 +148,32 @@ class RefreshAgendaAction : ActionCallback {
|
|||||||
*/
|
*/
|
||||||
private const val MAX_AGENDA_ROWS = 100
|
private const val MAX_AGENDA_ROWS = 100
|
||||||
|
|
||||||
/** Flat row model so the [LazyColumn] can mix day headers and events. */
|
/** Flat row model: one [LazyColumn] item per event or placeholder, a day's first one carrying its date pill. */
|
||||||
private sealed interface AgendaRow {
|
private sealed interface AgendaRow {
|
||||||
data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow
|
val date: LocalDate
|
||||||
data class Event(val date: LocalDate, val event: EventInstance) : AgendaRow
|
|
||||||
/** "Nothing left today" line under an anchored, event-less today (#35). */
|
data class Event(override val date: LocalDate, val event: EventInstance) : AgendaRow
|
||||||
data class Placeholder(val date: LocalDate) : AgendaRow
|
/** "Nothing left today" line for an anchored, event-less today (#35). */
|
||||||
|
data class Placeholder(override val date: LocalDate) : AgendaRow
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
private fun AgendaWidgetBody(data: AgendaWidgetData, preview: Boolean) {
|
||||||
// Read reactively from per-instance Glance state, falling back to the saved
|
// The row height is re-resolved against the system font scale so it tracks the
|
||||||
// pref for a freshly placed widget (#103, #51).
|
|
||||||
// The stripe is then re-resolved against the system font scale so it tracks the
|
|
||||||
// sp-sized text beside it instead of drifting at large accessibility settings.
|
// sp-sized text beside it instead of drifting at large accessibility settings.
|
||||||
val savedSize = (data as? AgendaWidgetData.Ready)?.savedWidgetSize ?: WidgetSize.SMALL
|
val size = (data as? AgendaWidgetData.Ready)?.textSize ?: WidgetSize.SMALL
|
||||||
val size = currentState(AGENDA_SIZE_KEY)
|
|
||||||
?.let { stored -> WidgetSize.entries.firstOrNull { it.name == stored } }
|
|
||||||
?: savedSize
|
|
||||||
val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale
|
val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale
|
||||||
val metrics = metricsFor(size).scaledForFont(fontScale)
|
val metrics = metricsFor(size).scaledForFont(fontScale)
|
||||||
Column(
|
Column(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(GlanceTheme.colors.surface)
|
|
||||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
) {
|
) {
|
||||||
AgendaHeader(metrics)
|
AgendaHeader()
|
||||||
Spacer(GlanceModifier.height(4.dp))
|
Spacer(GlanceModifier.height(6.dp))
|
||||||
when (data) {
|
when (data) {
|
||||||
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics)
|
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics)
|
||||||
is AgendaWidgetData.Ready -> {
|
is AgendaWidgetData.Ready -> AgendaRows(data = data, metrics = metrics, preview = preview)
|
||||||
// Range read reactively from per-instance Glance state (falls back
|
|
||||||
// to the saved pref for a freshly placed widget), then the loaded
|
|
||||||
// window is sliced to it — pure recomposition.
|
|
||||||
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
|
|
||||||
val context = androidx.glance.LocalContext.current
|
|
||||||
var loaded by remember(data) { mutableStateOf<AgendaWidgetData.Ready>(data) }
|
|
||||||
// A range wider than what was loaded reloads in a live session,
|
|
||||||
// where provideGlance doesn't run again (#363).
|
|
||||||
LaunchedEffect(range) {
|
|
||||||
val end = loaded.today.plus(
|
|
||||||
range.dayCount(loaded.today, loaded.weekStart) - 1,
|
|
||||||
DateTimeUnit.DAY,
|
|
||||||
)
|
|
||||||
if (end > loaded.loadedThrough) {
|
|
||||||
(context.loadAgendaWidgetData(range) as? AgendaWidgetData.Ready)
|
|
||||||
?.let { loaded = it }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AgendaRows(data = loaded, range = range, dark = dark, metrics = metrics)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,24 +181,16 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun AgendaRows(
|
private fun AgendaRows(
|
||||||
data: AgendaWidgetData.Ready,
|
data: AgendaWidgetData.Ready,
|
||||||
range: AgendaRange,
|
|
||||||
dark: Boolean,
|
|
||||||
metrics: AgendaMetrics,
|
metrics: AgendaMetrics,
|
||||||
|
preview: Boolean,
|
||||||
) {
|
) {
|
||||||
val pastDisplay =
|
val pastDisplay = data.pastDisplay
|
||||||
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
|
// Drop finished events (and any day they leave empty) when the mode is
|
||||||
val showToday = currentState(AGENDA_SHOW_TODAY_STATE_KEY) ?: data.savedShowToday
|
// Hide — so "Upcoming" really is. Then re-anchor today (as an empty
|
||||||
val rangeEnd = data.today.plus(
|
// placeholder day) when the "always show today" pref is on and today has
|
||||||
range.dayCount(data.today, data.weekStart) - 1,
|
// no events left (#35).
|
||||||
DateTimeUnit.DAY,
|
|
||||||
)
|
|
||||||
// Slice to the chosen range, then drop finished events (and any day
|
|
||||||
// they leave empty) when the mode is Hide — so "Upcoming" really is.
|
|
||||||
// Finally re-anchor today (as an empty placeholder day) when the
|
|
||||||
// "always show today" pref is on and today has no events left (#35).
|
|
||||||
val visibleDays = anchorTodayIfMissing(
|
val visibleDays = anchorTodayIfMissing(
|
||||||
days = data.days
|
days = data.days
|
||||||
.filter { it.date <= rangeEnd }
|
|
||||||
.let { days ->
|
.let { days ->
|
||||||
if (pastDisplay == PastEventDisplay.HIDE) {
|
if (pastDisplay == PastEventDisplay.HIDE) {
|
||||||
days.mapNotNull { day ->
|
days.mapNotNull { day ->
|
||||||
@@ -253,183 +202,223 @@ private fun AgendaRows(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
today = data.today,
|
today = data.today,
|
||||||
enabled = showToday,
|
enabled = data.showToday,
|
||||||
)
|
)
|
||||||
if (visibleDays.isEmpty()) {
|
if (visibleDays.isEmpty()) {
|
||||||
WidgetMessage(R.string.agenda_empty_title, metrics)
|
WidgetMessage(R.string.agenda_empty_title, metrics)
|
||||||
} else {
|
} else {
|
||||||
val rows = buildList {
|
val rows = buildList {
|
||||||
visibleDays.forEach { day ->
|
visibleDays.forEach { day ->
|
||||||
add(AgendaRow.Header(day.date, data.today))
|
|
||||||
if (day.events.isEmpty()) {
|
if (day.events.isEmpty()) {
|
||||||
add(AgendaRow.Placeholder(day.date))
|
add(AgendaRow.Placeholder(day.date))
|
||||||
} else {
|
} else {
|
||||||
day.events.forEach { add(AgendaRow.Event(day.date, it)) }
|
day.events.forEach { add(AgendaRow.Event(day.date, it)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}.take(MAX_AGENDA_ROWS)
|
||||||
|
// Each day is one card of grouped rows (#71), with its date pill
|
||||||
|
// beside the first row.
|
||||||
|
val positions = dayPositions(rows)
|
||||||
|
val cardColor = widgetCardColor()
|
||||||
|
val item: @Composable (Int) -> Unit = { index ->
|
||||||
|
val row = rows[index]
|
||||||
|
val position = positions[index]
|
||||||
|
val first = position == Position.Top || position == Position.Alone
|
||||||
|
val dayRow: @Composable (GlanceModifier) -> Unit = { modifier ->
|
||||||
|
Row(modifier = modifier.fillMaxWidth()) {
|
||||||
|
Box(modifier = GlanceModifier.width(metrics.pillWidth)) {
|
||||||
|
if (first) DatePill(row.date, data.today, cardColor, metrics)
|
||||||
}
|
}
|
||||||
// Bound the payload, then drop a day header the cut left
|
Spacer(GlanceModifier.width(GUTTER_GAP))
|
||||||
// stranded with nothing under it.
|
Box(modifier = GlanceModifier.defaultWeight()) {
|
||||||
.take(MAX_AGENDA_ROWS)
|
val rowColor = when (row) {
|
||||||
.dropLastWhile { it is AgendaRow.Header }
|
is AgendaRow.Event -> row.fill(data).let { themedColor(day = it.day, night = it.night) }
|
||||||
|
is AgendaRow.Placeholder -> cardColor
|
||||||
|
}
|
||||||
|
WidgetGroupedRow(position = position, color = rowColor) {
|
||||||
|
AgendaRowContent(row, data, metrics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val gap = GlanceModifier.padding(top = if (first && index > 0) metrics.dayGap else 0.dp)
|
||||||
|
// The pills carry no month, so a new month gets its name above its first day.
|
||||||
|
if (first && index > 0 && row.date.month != rows[index - 1].date.month) {
|
||||||
|
Column(modifier = gap.fillMaxWidth()) {
|
||||||
|
MonthLabel(row.date, data.today, metrics)
|
||||||
|
dayRow(GlanceModifier)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dayRow(gap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (preview) {
|
||||||
|
Column(modifier = GlanceModifier.fillMaxSize()) {
|
||||||
|
repeat(minOf(rows.size, PREVIEW_ROWS)) { item(it) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||||
items(rows.size) { index ->
|
items(rows.size) { item(it) }
|
||||||
when (val row = rows[index]) {
|
}
|
||||||
is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics)
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where each row sits among the rows of its day, for the grouped-card shapes. */
|
||||||
|
private fun dayPositions(rows: List<AgendaRow>): List<Position> = buildList {
|
||||||
|
var start = 0
|
||||||
|
for (end in 1..rows.size) {
|
||||||
|
if (end == rows.size || rows[end].date != rows[start].date) {
|
||||||
|
for (i in start until end) add(positionOf(i - start, end - start))
|
||||||
|
start = end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AgendaRowContent(row: AgendaRow, data: AgendaWidgetData.Ready, metrics: AgendaMetrics) {
|
||||||
|
when (row) {
|
||||||
is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics)
|
is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics)
|
||||||
is AgendaRow.Event -> EventRow(
|
is AgendaRow.Event -> EventRow(
|
||||||
event = row.event,
|
event = row.event,
|
||||||
day = row.date,
|
day = row.date,
|
||||||
dark = dark,
|
fill = row.fill(data),
|
||||||
soften = data.soften,
|
|
||||||
is24Hour = data.is24Hour,
|
is24Hour = data.is24Hour,
|
||||||
dimmed = pastDisplay == PastEventDisplay.DIM &&
|
|
||||||
row.event.hasEnded(data.now),
|
|
||||||
metrics = metrics,
|
metrics = metrics,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaHeader(metrics: AgendaMetrics) {
|
private fun AgendaHeader() {
|
||||||
val context = androidx.glance.LocalContext.current
|
val context = androidx.glance.LocalContext.current
|
||||||
Row(
|
|
||||||
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
// Tap the "Upcoming" title to open the app on the user's default view
|
// Tap the "Upcoming" title to open the app on the user's default view
|
||||||
// (issue #20) — a null target resolves to the default home in the host.
|
// (issue #20) — a null target resolves to the default home in the host.
|
||||||
Text(
|
WidgetHeader(
|
||||||
text = context.getString(R.string.widget_agenda_title),
|
title = context.getString(R.string.widget_agenda_title),
|
||||||
style = TextStyle(
|
titleAction = actionStartActivity(MainActivity.openViewIntent(context, view = null)),
|
||||||
color = GlanceTheme.colors.primary,
|
titleInset = 4.dp,
|
||||||
fontSize = metrics.title,
|
) {
|
||||||
fontWeight = FontWeight.Medium,
|
WidgetHeaderAction(
|
||||||
),
|
icon = ImageProvider(R.drawable.ic_widget_add),
|
||||||
modifier = GlanceModifier
|
|
||||||
.defaultWeight()
|
|
||||||
.clickable(
|
|
||||||
actionStartActivity(MainActivity.openViewIntent(context, view = null)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
IconButton(
|
|
||||||
resId = R.drawable.ic_widget_refresh,
|
|
||||||
contentDescription = context.getString(R.string.widget_refresh),
|
|
||||||
onClick = GlanceModifier.clickable(actionRunCallback<RefreshAgendaAction>()),
|
|
||||||
metrics = metrics,
|
|
||||||
)
|
|
||||||
IconButton(
|
|
||||||
resId = R.drawable.ic_widget_add,
|
|
||||||
contentDescription = context.getString(R.string.widget_new_event),
|
contentDescription = context.getString(R.string.widget_new_event),
|
||||||
onClick = GlanceModifier.clickable(
|
action = actionStartActivity(MainActivity.openCreateIntent(context, today(systemZone()))),
|
||||||
actionStartActivity(
|
|
||||||
MainActivity.openCreateIntent(context, today(systemZone())),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
metrics = metrics,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The month name ("November", with its year outside the current one) above a new month's first day. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun IconButton(
|
private fun MonthLabel(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) {
|
||||||
resId: Int,
|
|
||||||
contentDescription: String,
|
|
||||||
onClick: GlanceModifier,
|
|
||||||
metrics: AgendaMetrics,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = GlanceModifier.size(metrics.iconBox).then(onClick),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
provider = ImageProvider(resId),
|
|
||||||
contentDescription = contentDescription,
|
|
||||||
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
|
|
||||||
modifier = GlanceModifier.size(metrics.iconImage),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DayHeaderRow(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) {
|
|
||||||
val context = androidx.glance.LocalContext.current
|
|
||||||
Text(
|
Text(
|
||||||
text = agendaDayLabel(context, date, today),
|
text = monthName(date, today),
|
||||||
|
maxLines = 1,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
color = if (date == today) GlanceTheme.colors.primary
|
color = GlanceTheme.colors.onSurfaceVariant,
|
||||||
else GlanceTheme.colors.onSurfaceVariant,
|
fontSize = metrics.monthLabel,
|
||||||
fontSize = metrics.dayHeader,
|
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
),
|
),
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxWidth()
|
.padding(bottom = 4.dp)
|
||||||
.padding(
|
.wallpaperBacking(radius = 8.dp)
|
||||||
start = 8.dp,
|
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
end = 8.dp,
|
|
||||||
top = metrics.dayHeaderTopPad,
|
|
||||||
bottom = metrics.rowVPad,
|
|
||||||
)
|
|
||||||
.clickable(
|
|
||||||
actionStartActivity(
|
|
||||||
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** A day's weekday and number in a rounded pill, filled for today; opens that day in the app. */
|
||||||
* "No more events today" under an anchored, event-less today (#35). Kept plain —
|
@Composable
|
||||||
* a muted line indented to the event titles — since the widget's event rows are
|
private fun DatePill(date: LocalDate, today: LocalDate, cardColor: ColorProvider, metrics: AgendaMetrics) {
|
||||||
* plain too (a stripe + text, not cards), so a card here would look out of place.
|
val context = androidx.glance.LocalContext.current
|
||||||
*/
|
val isToday = date == today
|
||||||
|
val container = if (isToday) GlanceTheme.colors.primary else cardColor
|
||||||
|
val content = if (isToday) GlanceTheme.colors.onPrimary else GlanceTheme.colors.onSurface
|
||||||
|
val label = agendaDayLabel(context, date, today)
|
||||||
|
Column(
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.width(metrics.pillWidth)
|
||||||
|
.height(metrics.rowHeight)
|
||||||
|
.cornerRadius(metrics.pillWidth / 2)
|
||||||
|
.background(container)
|
||||||
|
.clickable(actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Agenda)))
|
||||||
|
.semantics { contentDescription = label },
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = weekdayShort(date),
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(color = content, fontSize = metrics.pillWeekday, textAlign = TextAlign.Center),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = date.day.toString(),
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(
|
||||||
|
color = content,
|
||||||
|
fontSize = metrics.pillDay,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "No more events today" in an anchored, event-less today's card (#35), as tall as the pill beside it. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) {
|
private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) {
|
||||||
val context = androidx.glance.LocalContext.current
|
val context = androidx.glance.LocalContext.current
|
||||||
Text(
|
Box(
|
||||||
text = context.getString(R.string.agenda_no_more_today),
|
|
||||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder),
|
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(
|
.height(metrics.rowHeight)
|
||||||
start = TEXT_INDENT,
|
.padding(horizontal = ROW_H_PAD)
|
||||||
end = 8.dp,
|
|
||||||
top = 2.dp,
|
|
||||||
bottom = metrics.rowVPad + 2.dp,
|
|
||||||
)
|
|
||||||
.clickable(
|
.clickable(
|
||||||
actionStartActivity(
|
actionStartActivity(
|
||||||
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
|
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = context.getString(R.string.agenda_no_more_today),
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An event row's light and dark fill: its calendar's container tone, faded when the row is dimmed. */
|
||||||
|
private data class EventFill(val day: Color, val night: Color)
|
||||||
|
|
||||||
|
private fun AgendaRow.Event.fill(data: AgendaWidgetData.Ready): EventFill {
|
||||||
|
val dimmed = data.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now)
|
||||||
|
fun tone(dark: Boolean) = eventFill(event.color, dark, data.soften).let {
|
||||||
|
if (dimmed) it.copy(alpha = EventDimAlpha) else it
|
||||||
|
}
|
||||||
|
return EventFill(day = tone(dark = false), night = tone(dark = true))
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EventRow(
|
private fun EventRow(
|
||||||
event: EventInstance,
|
event: EventInstance,
|
||||||
day: LocalDate,
|
day: LocalDate,
|
||||||
dark: Boolean,
|
fill: EventFill,
|
||||||
soften: Boolean,
|
|
||||||
is24Hour: Boolean,
|
is24Hour: Boolean,
|
||||||
dimmed: Boolean,
|
|
||||||
metrics: AgendaMetrics,
|
metrics: AgendaMetrics,
|
||||||
) {
|
) {
|
||||||
val context = androidx.glance.LocalContext.current
|
val context = androidx.glance.LocalContext.current
|
||||||
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
// Glance has no generic alpha modifier, so dim by fading the colour stripe and
|
// Ink is picked against the opaque fill, then carries the dim along with it.
|
||||||
// dropping both text lines to the lower-emphasis on-surface-variant tone.
|
fun ink(fill: Color, alpha: Float) = eventInk(fill.copy(alpha = 1f), alpha * fill.alpha)
|
||||||
val stripeColor = eventAccent(event.color, dark, soften).let {
|
val titleInk = themedColor(day = ink(fill.day, 1f), night = ink(fill.night, 1f))
|
||||||
if (dimmed) it.copy(alpha = EventDimAlpha) else it
|
val timeInk = themedColor(
|
||||||
}
|
day = ink(fill.day, SECONDARY_INK_ALPHA),
|
||||||
val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface
|
night = ink(fill.night, SECONDARY_INK_ALPHA),
|
||||||
Row(
|
)
|
||||||
|
Column(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = ROW_H_PAD, vertical = metrics.rowVPad)
|
.height(metrics.rowHeight)
|
||||||
|
.padding(horizontal = ROW_H_PAD)
|
||||||
.clickable(
|
.clickable(
|
||||||
actionStartActivity(
|
actionStartActivity(
|
||||||
MainActivity.openEventIntent(
|
MainActivity.openEventIntent(
|
||||||
@@ -443,32 +432,23 @@ private fun EventRow(
|
|||||||
),
|
),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Box(
|
|
||||||
modifier = GlanceModifier
|
|
||||||
.width(STRIPE_WIDTH)
|
|
||||||
.height(metrics.stripeH)
|
|
||||||
.cornerRadius(3.dp)
|
|
||||||
.background(stripeColor),
|
|
||||||
) {}
|
|
||||||
Spacer(GlanceModifier.width(STRIPE_GAP))
|
|
||||||
Column(modifier = GlanceModifier.defaultWeight()) {
|
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
color = titleColor,
|
color = titleInk,
|
||||||
fontSize = metrics.eventTitle,
|
fontSize = metrics.eventTitle,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
textDecoration = glanceDeclinedDecoration(event.isDeclined),
|
textDecoration = glanceDeclinedDecoration(event.isDeclined),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = eventTimeSummary(context, event, day, is24Hour),
|
text = eventTimeSummary(context, event, day, is24Hour),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.eventTime),
|
style = TextStyle(color = timeInk, fontSize = metrics.eventTime),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WidgetMessage(resId: Int, metrics: AgendaMetrics) {
|
private fun WidgetMessage(resId: Int, metrics: AgendaMetrics) {
|
||||||
@@ -480,12 +460,11 @@ private fun WidgetMessage(resId: Int, metrics: AgendaMetrics) {
|
|||||||
Text(
|
Text(
|
||||||
text = context.getString(resId),
|
text = context.getString(resId),
|
||||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.message),
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.message),
|
||||||
|
modifier = GlanceModifier.wallpaperBacking().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
@@ -501,26 +480,23 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
|
|||||||
return if (relative != null) "$relative · $formatted" else formatted
|
return if (relative != null) "$relative · $formatted" else formatted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun monthName(date: LocalDate, today: LocalDate): String = formatCalendarTitle(
|
||||||
|
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, 1),
|
||||||
|
locale = Locale.getDefault(),
|
||||||
|
currentYear = today.year,
|
||||||
|
skeleton = "LLLL",
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun weekdayShort(date: LocalDate): String =
|
||||||
|
date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, Locale.getDefault())
|
||||||
|
|
||||||
private fun eventTimeSummary(
|
private fun eventTimeSummary(
|
||||||
context: Context,
|
context: Context,
|
||||||
event: EventInstance,
|
event: EventInstance,
|
||||||
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
-38
@@ -1,44 +1,9 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.agenda
|
package de.jeanlucmakiola.calendula.widget.agenda
|
||||||
|
|
||||||
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.WidgetRolloverScheduler
|
|
||||||
|
|
||||||
/**
|
/** 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)
|
|
||||||
WidgetRolloverScheduler.sync(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Last agenda widget removed. [WidgetRolloverScheduler.sync] cancels only if
|
|
||||||
* no month widget is left either.
|
|
||||||
*/
|
|
||||||
override fun onDisabled(context: Context) {
|
|
||||||
super.onDisabled(context)
|
|
||||||
WidgetRolloverScheduler.sync(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Self-heal on the system's own `updatePeriodMillis` wake-up — see
|
|
||||||
* [de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver.onUpdate].
|
|
||||||
*/
|
|
||||||
override fun onUpdate(
|
|
||||||
context: Context,
|
|
||||||
appWidgetManager: AppWidgetManager,
|
|
||||||
appWidgetIds: IntArray,
|
|
||||||
) {
|
|
||||||
super.onUpdate(context, appWidgetManager, appWidgetIds)
|
|
||||||
WidgetRolloverScheduler.sync(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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.config
|
||||||
|
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||||
|
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||||
|
import de.jeanlucmakiola.calendula.widget.widgetState
|
||||||
|
|
||||||
|
/** One of the app's widgets on the home screen, for the settings list. */
|
||||||
|
data class PlacedWidget(val appWidgetId: Int, val kind: WidgetKind, val appearance: WidgetAppearance)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every placed widget, agenda ones first, each in placement order. An id the
|
||||||
|
* host bound but never laid out (a placement it abandoned without freeing the
|
||||||
|
* id) has no size yet and is left out, as is one removed while this reads.
|
||||||
|
*/
|
||||||
|
suspend fun Context.placedWidgets(): List<PlacedWidget> {
|
||||||
|
val manager = AppWidgetManager.getInstance(this) ?: return emptyList()
|
||||||
|
val glanceManager = GlanceAppWidgetManager(this)
|
||||||
|
return WidgetKind.entries.flatMap { kind ->
|
||||||
|
manager.getAppWidgetIds(ComponentName(this, kind.receiver)).sorted().mapNotNull { id ->
|
||||||
|
if (manager.getAppWidgetOptions(id).getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) <= 0) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
val glanceId = runCatching { glanceManager.getGlanceIdBy(id) }.getOrNull() ?: return@mapNotNull null
|
||||||
|
PlacedWidget(id, kind, widgetState(glanceId).widgetAppearance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.config
|
||||||
|
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.WindowThemeCache
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
|
||||||
|
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||||
|
import de.jeanlucmakiola.floret.prefs.get
|
||||||
|
import de.jeanlucmakiola.floret.prefs.isDark
|
||||||
|
import de.jeanlucmakiola.floret.prefs.isPureBlack
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A widget's config screen (#292): opened by the launcher when the widget is
|
||||||
|
* placed, from its "Edit widget" action on Android 12+, and from the app's
|
||||||
|
* widget settings. Shows over the wallpaper, so a transparent widget previews
|
||||||
|
* against the real home screen.
|
||||||
|
*/
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class WidgetConfigActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||||
|
|
||||||
|
private val viewModel: WidgetConfigViewModel by viewModels()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val appWidgetId = viewModel.appWidgetId
|
||||||
|
// Backing out of a fresh placement leaves no unconfigured widget behind.
|
||||||
|
setResult(RESULT_CANCELED, result(appWidgetId))
|
||||||
|
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enableEdgeToEdge()
|
||||||
|
lifecycleScope.launch {
|
||||||
|
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
|
viewModel.events.collect { event ->
|
||||||
|
when (event) {
|
||||||
|
WidgetConfigEvent.SAVED -> {
|
||||||
|
setResult(RESULT_OK, result(appWidgetId))
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
WidgetConfigEvent.SAVE_FAILED ->
|
||||||
|
Toast.makeText(this@WidgetConfigActivity, R.string.widget_config_save_failed, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val fontSettings = settingsPrefs.data.map {
|
||||||
|
AppFontSettings(
|
||||||
|
brand = it[SettingsKeys.brandFont],
|
||||||
|
plain = it[SettingsKeys.plainFont],
|
||||||
|
brandStamp = it[SettingsKeys.brandFontStamp],
|
||||||
|
plainStamp = it[SettingsKeys.plainFontStamp],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Seeded from MainActivity's mirrored copy, so an app theme override
|
||||||
|
// shows from the first frame.
|
||||||
|
val cachedTheme = WindowThemeCache.read(this) ?: ThemeMode.SYSTEM
|
||||||
|
val cachedDynamicColor = WindowThemeCache.readDynamicColor(this) ?: true
|
||||||
|
setContent {
|
||||||
|
val themeMode by settingsPrefs.themeMode.collectAsStateWithLifecycle(cachedTheme)
|
||||||
|
val dynamicColor by settingsPrefs.dynamicColor.collectAsStateWithLifecycle(cachedDynamicColor)
|
||||||
|
val fonts by fontSettings.collectAsStateWithLifecycle(initialValue = null)
|
||||||
|
val context = LocalContext.current
|
||||||
|
// Nothing until the font choice is known, rather than a frame in the default one.
|
||||||
|
val typography = remember(fonts, context) {
|
||||||
|
fonts?.let {
|
||||||
|
calendulaTypography(
|
||||||
|
brand = resolveFontFamily(it.brand, FontRole.BRAND, context),
|
||||||
|
plain = resolveFontFamily(it.plain, FontRole.PLAIN, context),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} ?: return@setContent
|
||||||
|
CalendulaTheme(
|
||||||
|
darkTheme = themeMode.isDark(isSystemInDarkTheme()),
|
||||||
|
dynamicColor = dynamicColor,
|
||||||
|
pureBlack = themeMode.isPureBlack,
|
||||||
|
typography = typography,
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
if (state is WidgetConfigUiState.Missing) LaunchedEffect(Unit) { finish() }
|
||||||
|
WidgetConfigScreen(
|
||||||
|
state = state,
|
||||||
|
onClose = ::finish,
|
||||||
|
onSave = viewModel::save,
|
||||||
|
onRetry = viewModel::retry,
|
||||||
|
onAppearance = viewModel::setAppearance,
|
||||||
|
onContent = viewModel::setContent,
|
||||||
|
onCalendarShown = viewModel::setCalendarShown,
|
||||||
|
onCountdown = viewModel::setCountdown,
|
||||||
|
onLoadCountdownCandidates = viewModel::loadCountdownCandidates,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun result(appWidgetId: Int) = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Opens placed widget [appWidgetId]'s config screen from inside the app. */
|
||||||
|
fun intent(context: Context, appWidgetId: Int): Intent =
|
||||||
|
Intent(context, WidgetConfigActivity::class.java)
|
||||||
|
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.config
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
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.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.settings.pastEventDisplayLabelRes
|
||||||
|
import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetOption
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetTheme
|
||||||
|
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetContent
|
||||||
|
import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
|
||||||
|
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.GroupedHint
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedSwitchRow
|
||||||
|
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.glance.GlanceWidgetPreview
|
||||||
|
import de.jeanlucmakiola.floret.glance.WallpaperWindow
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
private val PreviewMaxHeight = 280.dp
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun WidgetConfigScreen(
|
||||||
|
state: WidgetConfigUiState,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
onSave: () -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
onAppearance: (WidgetAppearance) -> Unit,
|
||||||
|
onContent: (AgendaWidgetOptions) -> Unit,
|
||||||
|
onCalendarShown: (Long, Boolean) -> Unit,
|
||||||
|
onCountdown: (CountdownOptions) -> Unit,
|
||||||
|
onLoadCountdownCandidates: () -> Unit,
|
||||||
|
) {
|
||||||
|
val ready = state as? WidgetConfigUiState.Ready
|
||||||
|
val surface = MaterialTheme.colorScheme.surface
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = ready?.let { stringResource(it.kind.label) }.orEmpty(),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onClose) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.event_edit_close))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
Button(
|
||||||
|
onClick = onSave,
|
||||||
|
enabled = ready != null && !ready.saving,
|
||||||
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.dialog_save))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(containerColor = surface),
|
||||||
|
)
|
||||||
|
when (state) {
|
||||||
|
WidgetConfigUiState.Loading, WidgetConfigUiState.Missing -> Box(
|
||||||
|
modifier = Modifier.fillMaxSize().background(surface),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
WidgetConfigUiState.Failed -> Column(
|
||||||
|
modifier = Modifier.fillMaxSize().background(surface).padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.widget_config_load_failed),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
FilledTonalButton(onClick = onRetry) { Text(stringResource(R.string.state_retry)) }
|
||||||
|
}
|
||||||
|
is WidgetConfigUiState.Ready -> {
|
||||||
|
WallpaperWindow(color = surface) {
|
||||||
|
val maxSize = DpSize(maxWidth, PreviewMaxHeight)
|
||||||
|
when (val preview = state.preview) {
|
||||||
|
null -> Box(
|
||||||
|
modifier = Modifier.padding(vertical = 16.dp).height(maxSize.height / 2),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (state.previewFailed) PreviewFailed() else CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
else -> GlanceWidgetPreview(
|
||||||
|
size = state.placedSize,
|
||||||
|
maxSize = maxSize,
|
||||||
|
preview,
|
||||||
|
state.appearance,
|
||||||
|
state.appTheme,
|
||||||
|
modifier = Modifier.padding(vertical = 16.dp),
|
||||||
|
placeholder = { CircularProgressIndicator() },
|
||||||
|
failure = { PreviewFailed() },
|
||||||
|
) {
|
||||||
|
when (preview) {
|
||||||
|
is WidgetPreviewData.Agenda ->
|
||||||
|
AgendaWidgetContent(preview.data, state.appearance, state.appTheme, preview = true)
|
||||||
|
is WidgetPreviewData.Month ->
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ConfigOptions(
|
||||||
|
state = state,
|
||||||
|
onAppearance = onAppearance,
|
||||||
|
onContent = onContent,
|
||||||
|
onCalendarShown = onCalendarShown,
|
||||||
|
onCountdown = onCountdown,
|
||||||
|
onLoadCountdownCandidates = onLoadCountdownCandidates,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.background(surface),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PreviewFailed() {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.widget_config_preview_failed),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.large)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ConfigOptions(
|
||||||
|
state: WidgetConfigUiState.Ready,
|
||||||
|
onAppearance: (WidgetAppearance) -> Unit,
|
||||||
|
onContent: (AgendaWidgetOptions) -> Unit,
|
||||||
|
onCalendarShown: (Long, Boolean) -> Unit,
|
||||||
|
onCountdown: (CountdownOptions) -> Unit,
|
||||||
|
onLoadCountdownCandidates: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(bottom = 16.dp),
|
||||||
|
) {
|
||||||
|
GroupedSectionHeader(stringResource(R.string.settings_section_appearance))
|
||||||
|
GroupedChoiceRow(
|
||||||
|
title = stringResource(R.string.widget_config_background),
|
||||||
|
options = WidgetBackground.entries,
|
||||||
|
selected = state.appearance.background,
|
||||||
|
label = { backgroundLabel(it) },
|
||||||
|
onSelect = { onAppearance(state.appearance.copy(background = it)) },
|
||||||
|
position = Position.Top,
|
||||||
|
)
|
||||||
|
GroupedChoiceRow(
|
||||||
|
title = stringResource(R.string.settings_theme),
|
||||||
|
options = WidgetTheme.entries,
|
||||||
|
selected = state.appearance.theme,
|
||||||
|
label = { themeLabel(it) },
|
||||||
|
onSelect = { onAppearance(state.appearance.copy(theme = it)) },
|
||||||
|
position = Position.Bottom,
|
||||||
|
)
|
||||||
|
if (state.kind == WidgetKind.COUNTDOWN) {
|
||||||
|
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
|
||||||
|
private fun ContentOptions(
|
||||||
|
state: WidgetConfigUiState.Ready,
|
||||||
|
onChange: (AgendaWidgetOptions) -> Unit,
|
||||||
|
onCalendarShown: (Long, Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
val options = state.draft.content
|
||||||
|
var showRange by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var showSize 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))
|
||||||
|
rows.forEach { option ->
|
||||||
|
when (option) {
|
||||||
|
WidgetOption.RANGE -> GroupedRow(
|
||||||
|
title = stringResource(R.string.widget_config_range),
|
||||||
|
summary = agendaRangeLabel(options.range),
|
||||||
|
position = position(option),
|
||||||
|
onClick = { showRange = true },
|
||||||
|
)
|
||||||
|
WidgetOption.TEXT_SIZE -> GroupedRow(
|
||||||
|
title = stringResource(R.string.widget_config_text_size),
|
||||||
|
summary = widgetSizeLabel(options.textSize),
|
||||||
|
position = position(option),
|
||||||
|
onClick = { showSize = true },
|
||||||
|
)
|
||||||
|
WidgetOption.PAST_DISPLAY -> GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_past_events),
|
||||||
|
summary = stringResource(pastEventDisplayLabelRes(options.pastDisplay)),
|
||||||
|
position = position(option),
|
||||||
|
onClick = { showPast = true },
|
||||||
|
)
|
||||||
|
WidgetOption.SHOW_TODAY -> GroupedSwitchRow(
|
||||||
|
title = stringResource(R.string.settings_agenda_show_today),
|
||||||
|
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) {
|
||||||
|
AgendaRangePicker(
|
||||||
|
title = stringResource(R.string.widget_config_range),
|
||||||
|
description = stringResource(R.string.settings_agenda_widget_range_hint),
|
||||||
|
selected = options.range,
|
||||||
|
weekStart = state.weekStart,
|
||||||
|
onSelect = { onChange(options.copy(range = it)) },
|
||||||
|
onDismiss = { showRange = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showSize) {
|
||||||
|
OptionPicker(
|
||||||
|
title = stringResource(R.string.widget_config_text_size),
|
||||||
|
header = { GroupedHint(stringResource(R.string.settings_widget_size_hint), verticalPadding = 8.dp) },
|
||||||
|
predictiveBack = true,
|
||||||
|
options = WidgetSize.entries,
|
||||||
|
selected = options.textSize,
|
||||||
|
label = { widgetSizeLabel(it) },
|
||||||
|
summary = { widgetSizeSummary(it) },
|
||||||
|
onSelect = { onChange(options.copy(textSize = it)) },
|
||||||
|
onDismiss = { showSize = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showPast) {
|
||||||
|
OptionPicker(
|
||||||
|
title = stringResource(R.string.settings_past_events),
|
||||||
|
header = { GroupedHint(stringResource(R.string.settings_past_events_hint), verticalPadding = 8.dp) },
|
||||||
|
predictiveBack = true,
|
||||||
|
options = PastEventDisplay.entries,
|
||||||
|
selected = options.pastDisplay,
|
||||||
|
label = { stringResource(pastEventDisplayLabelRes(it)) },
|
||||||
|
onSelect = { onChange(options.copy(pastDisplay = it)) },
|
||||||
|
onDismiss = { showPast = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun backgroundLabel(background: WidgetBackground): String = stringResource(
|
||||||
|
when (background) {
|
||||||
|
WidgetBackground.SOLID -> R.string.widget_config_background_solid
|
||||||
|
WidgetBackground.TINTED -> R.string.widget_config_background_tinted
|
||||||
|
WidgetBackground.TRANSPARENT -> R.string.widget_config_background_transparent
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun themeLabel(theme: WidgetTheme): String = stringResource(
|
||||||
|
when (theme) {
|
||||||
|
WidgetTheme.FOLLOW_APP -> R.string.widget_config_theme_follow_app
|
||||||
|
WidgetTheme.LIGHT -> R.string.settings_theme_light
|
||||||
|
WidgetTheme.DARK -> R.string.settings_theme_dark
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a size step does, as a percentage of the smallest one's event text.
|
||||||
|
* Derived from the widget's own [metricsFor] table rather than hardcoded.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun widgetSizeSummary(size: WidgetSize): String {
|
||||||
|
val base = metricsFor(WidgetSize.SMALL).eventTitle.value
|
||||||
|
val percent = (metricsFor(size).eventTitle.value / base * 100f).roundToInt()
|
||||||
|
return stringResource(R.string.settings_widget_size_summary, percent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun widgetSizeLabel(size: WidgetSize): String = stringResource(
|
||||||
|
when (size) {
|
||||||
|
WidgetSize.SMALL -> R.string.settings_widget_size_small
|
||||||
|
WidgetSize.MEDIUM -> R.string.settings_widget_size_medium
|
||||||
|
WidgetSize.LARGE -> R.string.settings_widget_size_large
|
||||||
|
WidgetSize.EXTRA_LARGE -> R.string.settings_widget_size_extra_large
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.config
|
||||||
|
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
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.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.widget.AgendaWidgetData
|
||||||
|
import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
|
||||||
|
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||||
|
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
|
||||||
|
import de.jeanlucmakiola.calendula.widget.loadThemedMonthSource
|
||||||
|
import de.jeanlucmakiola.calendula.widget.monthIndexOf
|
||||||
|
import de.jeanlucmakiola.calendula.widget.monthWindowCenter
|
||||||
|
import de.jeanlucmakiola.calendula.widget.agendaWidgetOptions
|
||||||
|
import de.jeanlucmakiola.calendula.widget.putAgendaOptions
|
||||||
|
import de.jeanlucmakiola.calendula.widget.putAppearance
|
||||||
|
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||||
|
import de.jeanlucmakiola.calendula.widget.today
|
||||||
|
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||||
|
import de.jeanlucmakiola.calendula.widget.widgetSettings
|
||||||
|
import de.jeanlucmakiola.calendula.widget.widgetState
|
||||||
|
import de.jeanlucmakiola.floret.glance.updateWithRefresh
|
||||||
|
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/** What the preview draws, loaded with the draft's options. */
|
||||||
|
sealed interface WidgetPreviewData {
|
||||||
|
data class Agenda(val data: AgendaWidgetData) : 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 {
|
||||||
|
data object Loading : WidgetConfigUiState
|
||||||
|
|
||||||
|
/** The widget is gone, or isn't one of ours; the screen closes. */
|
||||||
|
data object Missing : WidgetConfigUiState
|
||||||
|
|
||||||
|
/** Its stored options couldn't be read. */
|
||||||
|
data object Failed : WidgetConfigUiState
|
||||||
|
|
||||||
|
data class Ready(
|
||||||
|
val kind: WidgetKind,
|
||||||
|
val draft: WidgetDraft,
|
||||||
|
/** The calendars the app shows, for the widget's own calendar choice. */
|
||||||
|
val calendars: List<CalendarSource>,
|
||||||
|
val appTheme: ThemeMode,
|
||||||
|
val weekStart: DayOfWeek,
|
||||||
|
/** The widget's size on the home screen, which the preview is laid out at. */
|
||||||
|
val placedSize: DpSize,
|
||||||
|
/** Null until the first preview load lands. */
|
||||||
|
val preview: WidgetPreviewData?,
|
||||||
|
val candidates: CountdownCandidates = CountdownCandidates.Idle,
|
||||||
|
/** The first preview load failed, so there's nothing to show. */
|
||||||
|
val previewFailed: Boolean = false,
|
||||||
|
val saving: Boolean = false,
|
||||||
|
) : WidgetConfigUiState {
|
||||||
|
val appearance: WidgetAppearance get() = draft.appearance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-time outcomes of a save, for the screen that's showing when they land. */
|
||||||
|
enum class WidgetConfigEvent { SAVED, SAVE_FAILED }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backs [WidgetConfigActivity] (#292): the placed widget's stored options as an
|
||||||
|
* editable draft, a preview loaded with that draft, and the save that writes it
|
||||||
|
* into the widget's Glance state and redraws it.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class WidgetConfigViewModel @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val prefs: SettingsPrefs,
|
||||||
|
private val calendarPrefs: CalendarPrefs,
|
||||||
|
private val repository: CalendarRepository,
|
||||||
|
savedState: SavedStateHandle,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val appWidgetId: Int = savedState[AppWidgetManager.EXTRA_APPWIDGET_ID] ?: AppWidgetManager.INVALID_APPWIDGET_ID
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<WidgetConfigUiState>(WidgetConfigUiState.Loading)
|
||||||
|
val state: StateFlow<WidgetConfigUiState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val _events = Channel<WidgetConfigEvent>(Channel.BUFFERED)
|
||||||
|
val events: Flow<WidgetConfigEvent> = _events.receiveAsFlow()
|
||||||
|
|
||||||
|
private var previewJob: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch { load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun retry() {
|
||||||
|
if (_state.value != WidgetConfigUiState.Failed) return
|
||||||
|
_state.value = WidgetConfigUiState.Loading
|
||||||
|
viewModelScope.launch { load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun load() {
|
||||||
|
val kind = WidgetKind.of(context, appWidgetId)
|
||||||
|
if (kind == null) {
|
||||||
|
_state.value = WidgetConfigUiState.Missing
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_state.value = try {
|
||||||
|
val stored = context.widgetState(GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId))
|
||||||
|
val settings = prefs.widgetSettings.first()
|
||||||
|
WidgetConfigUiState.Ready(
|
||||||
|
kind = kind,
|
||||||
|
draft = WidgetDraft(
|
||||||
|
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,
|
||||||
|
weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()),
|
||||||
|
placedSize = placedSize(kind),
|
||||||
|
preview = null,
|
||||||
|
)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (_: IllegalArgumentException) {
|
||||||
|
// getGlanceIdBy: the widget was removed meanwhile.
|
||||||
|
WidgetConfigUiState.Missing
|
||||||
|
} catch (_: Exception) {
|
||||||
|
WidgetConfigUiState.Failed
|
||||||
|
}
|
||||||
|
reloadPreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The size the launcher placed the widget at (portrait), or a default before it has reported one. */
|
||||||
|
private fun placedSize(kind: WidgetKind): DpSize {
|
||||||
|
val options = AppWidgetManager.getInstance(context)?.getAppWidgetOptions(appWidgetId)
|
||||||
|
val width = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) ?: 0
|
||||||
|
val height = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT) ?: 0
|
||||||
|
if (width > 0 && height > 0) return DpSize(width.dp, height.dp)
|
||||||
|
return kind.defaultSize
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 setCalendarShown(id: Long, shown: Boolean) {
|
||||||
|
edit {
|
||||||
|
val hidden = it.draft.hiddenCalendars
|
||||||
|
it.copy(draft = it.draft.copy(hiddenCalendars = if (shown) hidden - id else hidden + id))
|
||||||
|
}
|
||||||
|
reloadPreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun edit(change: (WidgetConfigUiState.Ready) -> WidgetConfigUiState.Ready) {
|
||||||
|
_state.update { if (it is WidgetConfigUiState.Ready) change(it) else it }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reloadPreview() {
|
||||||
|
val ready = _state.value as? WidgetConfigUiState.Ready ?: return
|
||||||
|
previewJob?.cancel()
|
||||||
|
previewJob = viewModelScope.launch {
|
||||||
|
val preview = try {
|
||||||
|
loadPreview(ready)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// The options stay editable; the preview keeps what it had.
|
||||||
|
edit { it.copy(previewFailed = it.preview == null) }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
edit { it.copy(preview = preview, previewFailed = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadPreview(ready: WidgetConfigUiState.Ready): WidgetPreviewData {
|
||||||
|
val draft = ready.draft
|
||||||
|
val settings = prefs.widgetSettings.first()
|
||||||
|
return when (ready.kind) {
|
||||||
|
WidgetKind.AGENDA ->
|
||||||
|
WidgetPreviewData.Agenda(context.loadAgendaWidgetData(settings, draft.content, draft.hiddenCalendars))
|
||||||
|
WidgetKind.MONTH -> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes the draft into the widget and redraws it, then reports how that went on [events]. */
|
||||||
|
fun save() {
|
||||||
|
val ready = _state.value as? WidgetConfigUiState.Ready ?: return
|
||||||
|
if (ready.saving) return
|
||||||
|
edit { it.copy(saving = true) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId)
|
||||||
|
ready.kind.widget().updateWithRefresh(context, glanceId) { state ->
|
||||||
|
state.putAppearance(ready.draft.appearance)
|
||||||
|
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)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (_: IllegalArgumentException) {
|
||||||
|
_state.value = WidgetConfigUiState.Missing
|
||||||
|
} catch (_: Exception) {
|
||||||
|
edit { it.copy(saving = false) }
|
||||||
|
_events.send(WidgetConfigEvent.SAVE_FAILED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.month
|
||||||
|
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.DaySeating
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.seating
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.spanAt
|
||||||
|
|
||||||
|
/** A cell [cols] columns wide holding [item], or a merged gap where [item] is null. */
|
||||||
|
internal data class GridCell<T : Any>(val item: T?, val cols: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The seven columns as cells and the gaps between them, consecutive empty
|
||||||
|
* columns merged into one: the day-open taps come from the strip underneath,
|
||||||
|
* so a gap needs no per-column view. [cellAt] gives what starts at a column
|
||||||
|
* and how many columns it covers.
|
||||||
|
*/
|
||||||
|
internal fun <T : Any> mergeGaps(columns: Int, cellAt: (Int) -> GridCell<T>?): List<GridCell<T>> {
|
||||||
|
val cells = mutableListOf<GridCell<T>>()
|
||||||
|
var gap = 0
|
||||||
|
var col = 0
|
||||||
|
while (col < columns) {
|
||||||
|
val cell = cellAt(col)
|
||||||
|
if (cell == null) {
|
||||||
|
gap += 1
|
||||||
|
col += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (gap > 0) cells += GridCell(null, gap)
|
||||||
|
gap = 0
|
||||||
|
cells += cell
|
||||||
|
col += cell.cols
|
||||||
|
}
|
||||||
|
if (gap > 0) cells += GridCell(null, gap)
|
||||||
|
return cells
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One week of the grid seated with [laneCap] lanes. [showsOverflow] is off when
|
||||||
|
* the week has no room for even the "+N" row.
|
||||||
|
*/
|
||||||
|
internal class WeekLayout(val week: MonthWeek, val laneCap: Int, val showsOverflow: Boolean = true) {
|
||||||
|
private val seating: List<DaySeating> = week.days.indices.map { week.seating(it, laneCap) }
|
||||||
|
|
||||||
|
/** Lanes filled from the top; empty lanes below are not drawn. */
|
||||||
|
val usedLanes: Int = seating.maxOfOrNull { day -> day.seated.maxOfOrNull { it.lane + 1 } ?: 0 } ?: 0
|
||||||
|
|
||||||
|
/** Each drawn lane as bars and merged gaps. */
|
||||||
|
val lanes: List<List<GridCell<EventInstance>>> = List(usedLanes) { lane ->
|
||||||
|
mergeGaps(week.days.size) { col ->
|
||||||
|
val seat = seating[col].seated.firstOrNull { it.lane == lane } ?: return@mergeGaps null
|
||||||
|
val endCol = if (seat.bar) week.spanAt(col, lane)?.endCol ?: col else col
|
||||||
|
GridCell(seat.event, endCol - col + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The "+N" row as counts and merged gaps; empty when everything fits. */
|
||||||
|
val overflow: List<GridCell<Int>> = seating.map { it.hidden.size }.let { hidden ->
|
||||||
|
if (hidden.none { it > 0 }) emptyList()
|
||||||
|
else mergeGaps(hidden.size) { col -> hidden[col].takeIf { it > 0 }?.let { GridCell(it, 1) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event lanes a week row [weekHeight] tall holds under its day number, held to
|
||||||
|
* [MAX_LANES] so a tall widget's payload stays bounded (#214).
|
||||||
|
*/
|
||||||
|
internal fun lanesFitting(weekHeight: Dp): Int =
|
||||||
|
((weekHeight.value - DAY_ROW_DP - LANES_TOP_DP) / LANE_ROW_DP).toInt().coerceIn(0, MAX_LANES)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seats each week in [lanes] lanes. A week whose events need more gives its
|
||||||
|
* last lane to the "+N" row instead.
|
||||||
|
*/
|
||||||
|
internal fun fitWeeks(weeks: List<MonthWeek>, lanes: Int): List<WeekLayout> = weeks.map { week ->
|
||||||
|
if (lanes == 0) return@map WeekLayout(week, laneCap = 0, showsOverflow = false)
|
||||||
|
val needed = WeekLayout(week, UNCAPPED_LANES).usedLanes
|
||||||
|
WeekLayout(week, if (needed <= lanes) lanes else lanes - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lanes a week shows at most, however tall the widget. */
|
||||||
|
internal const val MAX_LANES = 8
|
||||||
|
|
||||||
|
private const val DAY_ROW_DP = 18f
|
||||||
|
private const val LANES_TOP_DP = 2f
|
||||||
|
private const val LANE_ROW_DP = 15f
|
||||||
|
private const val UNCAPPED_LANES = 64
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.month
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.text.SpannableString
|
||||||
|
import android.text.Spanned
|
||||||
|
import android.text.style.StrikethroughSpan
|
||||||
|
import android.text.style.StyleSpan
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.RemoteViews
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||||
|
import de.jeanlucmakiola.floret.glance.RemoteColor
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
|
||||||
|
import de.jeanlucmakiola.floret.glance.remoteColor
|
||||||
|
import de.jeanlucmakiola.floret.glance.setImageTint
|
||||||
|
import de.jeanlucmakiola.floret.glance.setTextColor
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.Month
|
||||||
|
|
||||||
|
/** The theme colours the plain-RemoteViews grid draws with. */
|
||||||
|
internal data class GridColors(
|
||||||
|
val onSurface: RemoteColor,
|
||||||
|
val onSurfaceVariant: RemoteColor,
|
||||||
|
val primary: RemoteColor,
|
||||||
|
val onPrimary: RemoteColor,
|
||||||
|
val cell: RemoteColor,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The month's week rows as one plain RemoteViews tree, embedded in the Glance
|
||||||
|
* widget so its views don't draw on Glance's view-id pool (#372): every event
|
||||||
|
* gets its bar, and only what can't fit the widget's height folds into "+N".
|
||||||
|
*
|
||||||
|
* A host may reapply a new rendering onto the views of the last one rather than
|
||||||
|
* inflate it afresh, so every container is cleared before it's filled and every
|
||||||
|
* view state is set both ways, never just when it differs from the layout.
|
||||||
|
*/
|
||||||
|
internal fun monthGridViews(
|
||||||
|
context: Context,
|
||||||
|
layouts: List<WeekLayout>,
|
||||||
|
currentMonth: Month,
|
||||||
|
today: LocalDate,
|
||||||
|
soften: Boolean,
|
||||||
|
colors: GridColors,
|
||||||
|
mode: WidgetThemeMode,
|
||||||
|
): RemoteViews {
|
||||||
|
val grid = RemoteViews(context.packageName, R.layout.widget_month_grid)
|
||||||
|
grid.removeAllViews(R.id.month_grid)
|
||||||
|
layouts.forEach { grid.addView(R.id.month_grid, weekViews(context, it, currentMonth, today, soften, colors, mode)) }
|
||||||
|
return grid
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun weekViews(
|
||||||
|
context: Context,
|
||||||
|
layout: WeekLayout,
|
||||||
|
currentMonth: Month,
|
||||||
|
today: LocalDate,
|
||||||
|
soften: Boolean,
|
||||||
|
colors: GridColors,
|
||||||
|
mode: WidgetThemeMode,
|
||||||
|
): RemoteViews {
|
||||||
|
val week = RemoteViews(context.packageName, R.layout.widget_month_week)
|
||||||
|
week.removeAllViews(R.id.week_lanes)
|
||||||
|
layout.week.days.forEachIndexed { col, date ->
|
||||||
|
week.setOnClickPendingIntent(STRIP_IDS[col], openDayIntent(context, date))
|
||||||
|
// Each day its own rounded cell, as in the app's month grid. Days around
|
||||||
|
// the month get a shade over their (opaque) cell, so they recede without
|
||||||
|
// letting a wallpaper show through.
|
||||||
|
week.setImageTint(context, CELL_IDS[col], colors.cell)
|
||||||
|
val outside = date.month != currentMonth
|
||||||
|
week.setViewVisibility(CELL_DIM_IDS[col], if (outside) View.VISIBLE else View.GONE)
|
||||||
|
if (outside) {
|
||||||
|
week.setImageTint(context, CELL_DIM_IDS[col], mode.remoteColor(OUTSIDE_SHADE_DAY, OUTSIDE_SHADE_NIGHT))
|
||||||
|
}
|
||||||
|
val dayId = DAY_IDS[col]
|
||||||
|
val isToday = date == today
|
||||||
|
week.setTextViewText(dayId, if (isToday) bold(date.day.toString()) else date.day.toString())
|
||||||
|
week.setTextColor(context, dayId, when {
|
||||||
|
isToday -> colors.onPrimary
|
||||||
|
date.month == currentMonth -> colors.onSurface
|
||||||
|
else -> colors.onSurfaceVariant
|
||||||
|
})
|
||||||
|
week.setViewVisibility(TODAY_IDS[col], if (isToday) View.VISIBLE else View.GONE)
|
||||||
|
if (isToday) week.setImageTint(context, TODAY_IDS[col], colors.primary)
|
||||||
|
}
|
||||||
|
layout.lanes.forEach { cells ->
|
||||||
|
val lane = laneViews(context)
|
||||||
|
cells.forEach { cell ->
|
||||||
|
val event = cell.item
|
||||||
|
lane.addView(R.id.lane, if (event == null) gap(context, cell.cols) else bar(context, event, cell.cols, soften, mode))
|
||||||
|
}
|
||||||
|
week.addView(R.id.week_lanes, lane)
|
||||||
|
}
|
||||||
|
if (layout.showsOverflow && layout.overflow.isNotEmpty()) {
|
||||||
|
val lane = laneViews(context)
|
||||||
|
layout.overflow.forEach { cell ->
|
||||||
|
val count = cell.item
|
||||||
|
if (count == null) {
|
||||||
|
lane.addView(R.id.lane, gap(context, cell.cols))
|
||||||
|
} else {
|
||||||
|
val more = RemoteViews(context.packageName, R.layout.widget_month_more)
|
||||||
|
more.setTextViewText(R.id.more, "+$count")
|
||||||
|
more.setTextColor(context, R.id.more, colors.onSurfaceVariant)
|
||||||
|
lane.addView(R.id.lane, more)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
week.addView(R.id.week_lanes, lane)
|
||||||
|
}
|
||||||
|
return week
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun laneViews(context: Context) =
|
||||||
|
RemoteViews(context.packageName, R.layout.widget_month_lane).apply { removeAllViews(R.id.lane) }
|
||||||
|
|
||||||
|
private fun gap(context: Context, cols: Int) = RemoteViews(context.packageName, GAP_LAYOUTS[cols - 1])
|
||||||
|
|
||||||
|
private fun bar(context: Context, event: EventInstance, cols: Int, soften: Boolean, mode: WidgetThemeMode): RemoteViews {
|
||||||
|
val views = RemoteViews(context.packageName, BAR_LAYOUTS[cols - 1])
|
||||||
|
val dayFill = eventFill(event.color, dark = false, soften)
|
||||||
|
val nightFill = eventFill(event.color, dark = true, soften)
|
||||||
|
views.setImageTint(context, R.id.bar_bg, mode.remoteColor(dayFill, nightFill))
|
||||||
|
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
|
// A span, like Glance's own decoration: paint flags would drop the text's defaults.
|
||||||
|
views.setTextViewText(R.id.bar_title, if (event.isDeclined) span(title, StrikethroughSpan()) else title)
|
||||||
|
views.setTextColor(context, R.id.bar_title, mode.remoteColor(eventInk(dayFill), eventInk(nightFill)))
|
||||||
|
return views
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bold(text: String): CharSequence = span(text, StyleSpan(Typeface.BOLD))
|
||||||
|
|
||||||
|
private fun span(text: String, span: Any): CharSequence =
|
||||||
|
SpannableString(text).apply { setSpan(span, 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) }
|
||||||
|
|
||||||
|
private fun openDayIntent(context: Context, date: LocalDate): PendingIntent = PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
date.toEpochDays().toInt(),
|
||||||
|
MainActivity.openDateIntent(context, date, CalendarView.Month),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val STRIP_IDS = intArrayOf(R.id.strip_0, R.id.strip_1, R.id.strip_2, R.id.strip_3, R.id.strip_4, R.id.strip_5, R.id.strip_6)
|
||||||
|
/** The shade over a day outside the month: a dark cell needs more of it to read as dimmed. */
|
||||||
|
private val OUTSIDE_SHADE_DAY = Color.Black.copy(alpha = 0.18f)
|
||||||
|
private val OUTSIDE_SHADE_NIGHT = Color.Black.copy(alpha = 0.50f)
|
||||||
|
|
||||||
|
private val CELL_DIM_IDS = intArrayOf(
|
||||||
|
R.id.cell_dim_0, R.id.cell_dim_1, R.id.cell_dim_2, R.id.cell_dim_3, R.id.cell_dim_4, R.id.cell_dim_5, R.id.cell_dim_6,
|
||||||
|
)
|
||||||
|
private val CELL_IDS = intArrayOf(R.id.cell_0, R.id.cell_1, R.id.cell_2, R.id.cell_3, R.id.cell_4, R.id.cell_5, R.id.cell_6)
|
||||||
|
private val DAY_IDS = intArrayOf(R.id.day_0, R.id.day_1, R.id.day_2, R.id.day_3, R.id.day_4, R.id.day_5, R.id.day_6)
|
||||||
|
private val TODAY_IDS = intArrayOf(
|
||||||
|
R.id.today_bg_0, R.id.today_bg_1, R.id.today_bg_2, R.id.today_bg_3, R.id.today_bg_4, R.id.today_bg_5, R.id.today_bg_6,
|
||||||
|
)
|
||||||
|
private val BAR_LAYOUTS = intArrayOf(
|
||||||
|
R.layout.widget_month_bar_1, R.layout.widget_month_bar_2, R.layout.widget_month_bar_3, R.layout.widget_month_bar_4,
|
||||||
|
R.layout.widget_month_bar_5, R.layout.widget_month_bar_6, R.layout.widget_month_bar_7,
|
||||||
|
)
|
||||||
|
private val GAP_LAYOUTS = intArrayOf(
|
||||||
|
R.layout.widget_month_gap_1, R.layout.widget_month_gap_2, R.layout.widget_month_gap_3, R.layout.widget_month_gap_4,
|
||||||
|
R.layout.widget_month_gap_5, R.layout.widget_month_gap_6, R.layout.widget_month_gap_7,
|
||||||
|
)
|
||||||
@@ -1,76 +1,83 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.month
|
package de.jeanlucmakiola.calendula.widget.month
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||||
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
|
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||||
|
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
import androidx.glance.ColorFilter
|
|
||||||
import androidx.glance.GlanceId
|
import androidx.glance.GlanceId
|
||||||
import androidx.glance.GlanceModifier
|
import androidx.glance.GlanceModifier
|
||||||
import androidx.glance.GlanceTheme
|
import androidx.glance.GlanceTheme
|
||||||
import androidx.glance.Image
|
|
||||||
import androidx.glance.ImageProvider
|
import androidx.glance.ImageProvider
|
||||||
import androidx.glance.LocalContext
|
import androidx.glance.LocalContext
|
||||||
import androidx.glance.LocalSize
|
import androidx.glance.LocalSize
|
||||||
import androidx.glance.action.ActionParameters
|
import androidx.glance.action.ActionParameters
|
||||||
import androidx.glance.action.actionParametersOf
|
import androidx.glance.action.actionParametersOf
|
||||||
import androidx.glance.action.clickable
|
|
||||||
import androidx.glance.appwidget.GlanceAppWidget
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
import androidx.glance.appwidget.AndroidRemoteViews
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||||
|
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
|
||||||
|
import androidx.glance.background
|
||||||
|
import androidx.glance.appwidget.cornerRadius
|
||||||
import androidx.glance.appwidget.SizeMode
|
import androidx.glance.appwidget.SizeMode
|
||||||
import androidx.glance.appwidget.action.ActionCallback
|
import androidx.glance.appwidget.action.ActionCallback
|
||||||
import androidx.glance.appwidget.action.actionRunCallback
|
import androidx.glance.appwidget.action.actionRunCallback
|
||||||
import androidx.glance.appwidget.action.actionStartActivity
|
import androidx.glance.appwidget.action.actionStartActivity
|
||||||
import androidx.glance.appwidget.cornerRadius
|
|
||||||
import androidx.glance.appwidget.provideContent
|
import androidx.glance.appwidget.provideContent
|
||||||
import androidx.glance.appwidget.state.getAppWidgetState
|
import androidx.glance.appwidget.state.getAppWidgetState
|
||||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||||
import androidx.glance.background
|
|
||||||
import androidx.glance.currentState
|
import androidx.glance.currentState
|
||||||
import androidx.glance.layout.Alignment
|
import androidx.glance.layout.Alignment
|
||||||
import androidx.glance.layout.Box
|
import androidx.glance.layout.Box
|
||||||
import androidx.glance.layout.Column
|
import androidx.glance.layout.Column
|
||||||
import androidx.glance.layout.Row
|
import androidx.glance.layout.Row
|
||||||
import androidx.glance.layout.Spacer
|
import androidx.glance.layout.Spacer
|
||||||
import androidx.glance.layout.fillMaxHeight
|
|
||||||
import androidx.glance.layout.fillMaxSize
|
import androidx.glance.layout.fillMaxSize
|
||||||
import androidx.glance.layout.fillMaxWidth
|
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 androidx.glance.text.FontWeight
|
import de.jeanlucmakiola.floret.glance.LocalWidgetThemeMode
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetColorRole
|
||||||
|
import de.jeanlucmakiola.floret.glance.remoteColor
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetActionStyle
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetHeader
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetHeaderAction
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetHeaderActionGap
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetHeaderTextAction
|
||||||
import androidx.glance.text.Text
|
import androidx.glance.text.Text
|
||||||
import androidx.glance.text.TextAlign
|
import androidx.glance.text.TextAlign
|
||||||
import androidx.glance.text.TextStyle
|
import androidx.glance.text.TextStyle
|
||||||
import androidx.glance.unit.ColorProvider
|
|
||||||
import de.jeanlucmakiola.calendula.MainActivity
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
|
||||||
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||||
|
import de.jeanlucmakiola.floret.glance.wallpaperBacking
|
||||||
|
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
||||||
import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource
|
import de.jeanlucmakiola.calendula.widget.loadCachedMonthSource
|
||||||
|
import de.jeanlucmakiola.calendula.widget.monthWindowCenter
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetData
|
||||||
|
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
|
||||||
|
import de.jeanlucmakiola.floret.glance.refreshStamp
|
||||||
|
import de.jeanlucmakiola.floret.glance.updateWithRefresh
|
||||||
import de.jeanlucmakiola.calendula.widget.monthIndexOf
|
import de.jeanlucmakiola.calendula.widget.monthIndexOf
|
||||||
import de.jeanlucmakiola.calendula.widget.systemZone
|
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||||
import de.jeanlucmakiola.calendula.widget.today
|
import de.jeanlucmakiola.calendula.widget.today
|
||||||
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@@ -83,12 +90,16 @@ import java.util.Locale
|
|||||||
/** Per-widget state: the displayed month as `year * 12 + monthOrdinal`. */
|
/** Per-widget state: the displayed month as `year * 12 + monthOrdinal`. */
|
||||||
private val MONTH_INDEX_KEY = intPreferencesKey("month_index")
|
private val MONTH_INDEX_KEY = intPreferencesKey("month_index")
|
||||||
|
|
||||||
/** Event rows (lanes) shown per week before the rest collapse into "+N". */
|
|
||||||
private const val MAX_LANES = 3
|
|
||||||
private val LANE_HEIGHT = 14.dp
|
|
||||||
private val DAY_NUMBER_HEIGHT = 18.dp
|
|
||||||
private val GRID_HPADDING = 8.dp
|
private val GRID_HPADDING = 8.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Height around the week rows: body padding, the header's bottom padding and
|
||||||
|
* spacer and the weekday pills' padding, then the header (its 28dp pills, or a title line in its transparent
|
||||||
|
* pill when that's taller) and the weekday row, the text lines at [fontScale].
|
||||||
|
*/
|
||||||
|
private fun gridChromeHeight(fontScale: Float): Dp =
|
||||||
|
21.dp + maxOf(28.dp, 19.dp * fontScale + 8.dp) + 15.dp * fontScale
|
||||||
|
|
||||||
private fun currentMonthIndex(zone: TimeZone): Int = monthIndexOf(today(zone))
|
private fun currentMonthIndex(zone: TimeZone): Int = monthIndexOf(today(zone))
|
||||||
|
|
||||||
private fun yearMonthOf(index: Int): YearMonth =
|
private fun yearMonthOf(index: Int): YearMonth =
|
||||||
@@ -99,9 +110,9 @@ private fun yearMonthOf(index: Int): YearMonth =
|
|||||||
* event bars and titled single-day pills (the in-app lane layout via
|
* event bars and titled single-day pills (the in-app lane layout via
|
||||||
* [layoutMonthWeeks]), and prev/next/today navigation.
|
* [layoutMonthWeeks]), and prev/next/today navigation.
|
||||||
*
|
*
|
||||||
* Columns are sized explicitly from [LocalSize] (hence [SizeMode.Exact]) so a
|
* The week rows are one plain RemoteViews tree ([monthGridViews]) embedded in
|
||||||
* multi-day span renders as a single Box spanning its columns — connected, no
|
* the Glance widget, so a busy month isn't held to Glance's view-id pool (#372);
|
||||||
* inter-cell seam, with rounded end caps. The displayed month lives in Glance
|
* the lanes each week shows come from the widget's height. The displayed month lives in Glance
|
||||||
* state and is read reactively in the composition ([currentState]) so the arrows
|
* state and is read reactively in the composition ([currentState]) so the arrows
|
||||||
* move it via plain recomposition, not a (here-unreliable) widget session reload.
|
* move it via plain recomposition, not a (here-unreliable) widget session reload.
|
||||||
*
|
*
|
||||||
@@ -112,7 +123,7 @@ private fun yearMonthOf(index: Int): YearMonth =
|
|||||||
* the launcher rebinds. [SizeMode.Exact] serialises the grid once per size the
|
* the launcher rebinds. [SizeMode.Exact] serialises the grid once per size the
|
||||||
* host reports, so the ceiling arrives at half the view count you would expect: a
|
* host reports, so the ceiling arrives at half the view count you would expect: a
|
||||||
* grid that emitted a view per cell blew past it after three taps (#214). Every
|
* grid that emitted a view per cell blew past it after three taps (#214). Every
|
||||||
* row here emits only what it fills.
|
* row here emits only what it fills, and lanes stop at what the height shows.
|
||||||
*/
|
*/
|
||||||
class MonthWidget : GlanceAppWidget() {
|
class MonthWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
@@ -122,25 +133,58 @@ class MonthWidget : GlanceAppWidget() {
|
|||||||
* Exact rather than [SizeMode.Single] or [SizeMode.Responsive], both of which
|
* Exact rather than [SizeMode.Single] or [SizeMode.Responsive], both of which
|
||||||
* return early from `GlanceAppWidget.resize` — under those the grid is never
|
* return early from `GlanceAppWidget.resize` — under those the grid is never
|
||||||
* recomposed for a new size and a resized widget clips instead of reflowing.
|
* recomposed for a new size and a resized widget clips instead of reflowing.
|
||||||
* Exact costs one serialised copy per host size, which is affordable now that
|
* Exact costs one serialised copy per host size, so the grid draws only the
|
||||||
* a rendering is ~190 views rather than ~740.
|
* lanes the size can show, folding the rest into "+N".
|
||||||
*/
|
*/
|
||||||
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]
|
||||||
val source = context.loadMonthWidgetSource(shown)
|
?: currentMonthIndex(systemZone())
|
||||||
val dark = (context.resources.configuration.uiMode and
|
val stamp = refreshStamp(context, id)
|
||||||
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
val initialCenter = monthWindowCenter(shown)
|
||||||
// Read fresh (not through the cached source) so toggling the softener
|
// Shared by every size Exact composes, so each loads nothing of its own.
|
||||||
// redraws with the new choice; it's one cheap DataStore read.
|
val widgetData = WidgetData(context.loadCachedMonthSource(id, initialCenter, stamp), initialCenter, stamp)
|
||||||
val soften = context.widgetEntryPoint().settingsPrefs().softenCalendarColors.first()
|
|
||||||
provideContent {
|
provideContent {
|
||||||
CalendulaGlanceTheme {
|
val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(systemZone())
|
||||||
MonthWidgetBody(source = source, dark = dark, soften = soften)
|
// Reloads when paging leaves the loaded window, and on every redraw,
|
||||||
|
// which a running session would otherwise never see (#420).
|
||||||
|
val center = monthWindowCenter(index)
|
||||||
|
val currentStamp = currentRefreshStamp()
|
||||||
|
val loaded = widgetData.current(center, currentStamp) {
|
||||||
|
context.loadCachedMonthSource(id, center, currentStamp)
|
||||||
|
}
|
||||||
|
val source = loaded.content
|
||||||
|
// A jump out of the loaded window keeps the last month it covers
|
||||||
|
// until the reload lands, rather than drawing an empty grid.
|
||||||
|
var lastShown by remember { mutableIntStateOf(index) }
|
||||||
|
val shown = when {
|
||||||
|
source !is MonthWidgetSource.Ready || index in source.months -> index
|
||||||
|
lastShown in source.months -> lastShown
|
||||||
|
else -> index.coerceIn(source.months)
|
||||||
|
}
|
||||||
|
SideEffect { lastShown = shown }
|
||||||
|
MonthWidgetContent(
|
||||||
|
source = source,
|
||||||
|
index = shown,
|
||||||
|
appearance = currentState<Preferences>().widgetAppearance,
|
||||||
|
appTheme = loaded.appTheme,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The whole month widget showing month [index], shared with its config screen's preview (#292). */
|
||||||
|
@Composable
|
||||||
|
internal fun MonthWidgetContent(source: MonthWidgetSource, index: Int, appearance: WidgetAppearance, appTheme: ThemeMode) {
|
||||||
|
CalendulaWidgetRoot(appearance, appTheme) {
|
||||||
|
MonthWidgetBody(source = source, index = index)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -179,57 +223,53 @@ class ShiftMonthAction : ActionCallback {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Jump the displayed month back to the current month. See [ShiftMonthAction]. */
|
/** Jump the displayed month back to the current month, reloading it. See [ShiftMonthAction]. */
|
||||||
class ResetMonthAction : ActionCallback {
|
class ResetMonthAction : ActionCallback {
|
||||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||||
updateAppWidgetState(context, glanceId) { prefs -> prefs.remove(MONTH_INDEX_KEY) }
|
updateAppWidgetState(context, glanceId) { prefs -> prefs.remove(MONTH_INDEX_KEY) }
|
||||||
MonthWidget().update(context.applicationContext, glanceId)
|
MonthWidget().updateWithRefresh(context.applicationContext, glanceId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Boolean) {
|
private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) {
|
||||||
Column(
|
Column(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(GlanceTheme.colors.surface)
|
|
||||||
.padding(horizontal = GRID_HPADDING, vertical = 6.dp),
|
.padding(horizontal = GRID_HPADDING, vertical = 6.dp),
|
||||||
) {
|
) {
|
||||||
when (source) {
|
when (source) {
|
||||||
MonthWidgetSource.NeedsPermission -> {
|
MonthWidgetSource.NeedsPermission -> {
|
||||||
MonthHeader(label = "Calendula")
|
MonthHeader(label = "Calendula", today = today(systemZone()))
|
||||||
PermissionMessage()
|
PermissionMessage()
|
||||||
}
|
}
|
||||||
is MonthWidgetSource.Ready -> {
|
is MonthWidgetSource.Ready -> {
|
||||||
val zone = systemZone()
|
val zone = systemZone()
|
||||||
val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone)
|
|
||||||
val ym = yearMonthOf(index)
|
val ym = yearMonthOf(index)
|
||||||
// Paging past the loaded months reloads around the new one in a
|
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
|
||||||
// live session, where provideGlance doesn't run again.
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var loaded by remember(source) { mutableStateOf<MonthWidgetSource.Ready>(source) }
|
val fontScale = context.resources.configuration.fontScale
|
||||||
LaunchedEffect(index) {
|
val weekHeight = (LocalSize.current.height - gridChromeHeight(fontScale)) / weeks.size
|
||||||
if (index !in loaded.months) {
|
val layouts = fitWeeks(weeks, lanesFitting(weekHeight))
|
||||||
(context.loadMonthWidgetSource(index) as? MonthWidgetSource.Ready)
|
val mode = LocalWidgetThemeMode.current
|
||||||
?.let { loaded = it }
|
val colors = GridColors(
|
||||||
}
|
onSurface = remoteColor(WidgetColorRole.ON_SURFACE),
|
||||||
}
|
onSurfaceVariant = remoteColor(WidgetColorRole.ON_SURFACE_VARIANT),
|
||||||
// Column width from the live widget size, minus our H padding.
|
primary = remoteColor(WidgetColorRole.PRIMARY),
|
||||||
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
|
onPrimary = remoteColor(WidgetColorRole.ON_PRIMARY),
|
||||||
val weeks = layoutMonthWeeks(ym, loaded.weekStart, loaded.instances, zone)
|
cell = remoteColor(monthCellRole()),
|
||||||
|
)
|
||||||
|
|
||||||
MonthHeader(label = monthLabel(ym, loaded.today.year))
|
MonthHeader(label = monthLabel(ym, source.today.year), today = source.today)
|
||||||
Spacer(GlanceModifier.height(2.dp))
|
Spacer(GlanceModifier.height(4.dp))
|
||||||
WeekdayHeader(weekStart = loaded.weekStart, colW = colW)
|
// No backing behind the grid: the weekday pills and day cells
|
||||||
weeks.forEach { week ->
|
// each carry their own fill, so a transparent widget stays
|
||||||
WeekRow(
|
// readable without one blob over the wallpaper (#292).
|
||||||
week = week,
|
Column(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) {
|
||||||
currentMonth = ym.month,
|
WeekdayHeader(weekStart = source.weekStart)
|
||||||
today = loaded.today,
|
AndroidRemoteViews(
|
||||||
dark = dark,
|
remoteViews = monthGridViews(context, layouts, ym.month, source.today, source.soften, colors, mode),
|
||||||
soften = soften,
|
modifier = GlanceModifier.fillMaxWidth().defaultWeight(),
|
||||||
colW = colW,
|
|
||||||
modifier = GlanceModifier.defaultWeight(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,88 +278,78 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MonthHeader(label: String) {
|
private fun MonthHeader(label: String, today: LocalDate) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
Row(
|
|
||||||
modifier = GlanceModifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
HeaderIcon(
|
|
||||||
resId = R.drawable.ic_widget_chevron_left,
|
|
||||||
contentDescription = context.getString(R.string.widget_prev_month),
|
|
||||||
onClick = GlanceModifier.clickable(
|
|
||||||
actionRunCallback<ShiftMonthAction>(
|
|
||||||
actionParametersOf(ShiftMonthAction.deltaKey to -1),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
// Tap the month/year title to open the app on the month view (issue #18),
|
// Tap the month/year title to open the app on the month view (issue #18),
|
||||||
// rooted over the default home so backing out returns there.
|
// rooted over the default home so backing out returns there.
|
||||||
Text(
|
WidgetHeader(
|
||||||
text = label,
|
title = label,
|
||||||
style = TextStyle(
|
titleAction = actionStartActivity(MainActivity.openViewIntent(context, CalendarView.Month)),
|
||||||
color = GlanceTheme.colors.primary,
|
modifier = GlanceModifier.padding(bottom = 2.dp),
|
||||||
fontSize = 15.sp,
|
titleInset = 4.dp,
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
),
|
|
||||||
modifier = GlanceModifier
|
|
||||||
.defaultWeight()
|
|
||||||
.clickable(
|
|
||||||
actionStartActivity(
|
|
||||||
MainActivity.openViewIntent(context, CalendarView.Month),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
// The "today" button snaps the grid back to the current month in place.
|
|
||||||
HeaderIcon(
|
|
||||||
resId = R.drawable.ic_widget_today,
|
|
||||||
contentDescription = context.getString(R.string.widget_today),
|
|
||||||
onClick = GlanceModifier.clickable(actionRunCallback<ResetMonthAction>()),
|
|
||||||
)
|
|
||||||
HeaderIcon(
|
|
||||||
resId = R.drawable.ic_widget_chevron_right,
|
|
||||||
contentDescription = context.getString(R.string.widget_next_month),
|
|
||||||
onClick = GlanceModifier.clickable(
|
|
||||||
actionRunCallback<ShiftMonthAction>(
|
|
||||||
actionParametersOf(ShiftMonthAction.deltaKey to 1),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun HeaderIcon(resId: Int, contentDescription: String, onClick: GlanceModifier) {
|
|
||||||
Box(
|
|
||||||
modifier = GlanceModifier.size(40.dp).then(onClick),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
) {
|
||||||
Image(
|
WidgetHeaderAction(
|
||||||
provider = ImageProvider(resId),
|
icon = ImageProvider(R.drawable.ic_widget_chevron_left),
|
||||||
contentDescription = contentDescription,
|
contentDescription = context.getString(R.string.widget_prev_month),
|
||||||
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
|
action = actionRunCallback<ShiftMonthAction>(actionParametersOf(ShiftMonthAction.deltaKey to -1)),
|
||||||
modifier = GlanceModifier.size(20.dp),
|
style = WidgetActionStyle.TONAL,
|
||||||
|
)
|
||||||
|
WidgetHeaderActionGap()
|
||||||
|
// The "today" pill carries today's number like the app icon, and snaps
|
||||||
|
// the grid back to the current month in place.
|
||||||
|
WidgetHeaderTextAction(
|
||||||
|
text = today.day.toString(),
|
||||||
|
contentDescription = context.getString(R.string.widget_today),
|
||||||
|
action = actionRunCallback<ResetMonthAction>(),
|
||||||
|
style = WidgetActionStyle.TONAL,
|
||||||
|
)
|
||||||
|
WidgetHeaderActionGap()
|
||||||
|
WidgetHeaderAction(
|
||||||
|
icon = ImageProvider(R.drawable.ic_widget_chevron_right),
|
||||||
|
contentDescription = context.getString(R.string.widget_next_month),
|
||||||
|
action = actionRunCallback<ShiftMonthAction>(actionParametersOf(ShiftMonthAction.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)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WeekdayHeader(weekStart: DayOfWeek, colW: Dp) {
|
private fun WeekdayHeader(weekStart: DayOfWeek) {
|
||||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
Row(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 1.dp)) {
|
||||||
weekdayNarrowNames(weekStart).forEach { name ->
|
weekdayNarrowNames(weekStart).forEach { name ->
|
||||||
|
Box(modifier = GlanceModifier.defaultWeight().padding(horizontal = 1.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = name,
|
text = name,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
color = GlanceTheme.colors.onSurfaceVariant,
|
color = GlanceTheme.colors.onPrimaryContainer,
|
||||||
fontSize = 11.sp,
|
fontSize = 11.sp,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
),
|
),
|
||||||
modifier = GlanceModifier.width(colW),
|
modifier = GlanceModifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.cornerRadius(8.dp)
|
||||||
|
.background(GlanceTheme.colors.primaryContainer)
|
||||||
|
.padding(vertical = 1.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The fill of a day cell: one tone off a filled root, the widget surface over a wallpaper. */
|
||||||
|
private fun monthCellRole(background: WidgetBackground): WidgetColorRole = when (background) {
|
||||||
|
WidgetBackground.SOLID -> WidgetColorRole.SURFACE_VARIANT
|
||||||
|
WidgetBackground.TINTED, WidgetBackground.TRANSPARENT -> WidgetColorRole.WIDGET_BACKGROUND
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun monthCellRole(): WidgetColorRole = monthCellRole(LocalWidgetBackground.current)
|
||||||
|
|
||||||
/** Narrow weekday initials starting at [weekStart], in the device locale.
|
/** Narrow weekday initials starting at [weekStart], in the device locale.
|
||||||
* Computed outside the composable so the locale read stays observable-safe. */
|
* Computed outside the composable so the locale read stays observable-safe. */
|
||||||
@@ -331,266 +361,6 @@ private fun weekdayNarrowNames(weekStart: DayOfWeek): List<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun WeekRow(
|
|
||||||
week: MonthWeek,
|
|
||||||
currentMonth: Month,
|
|
||||||
today: LocalDate,
|
|
||||||
dark: Boolean,
|
|
||||||
soften: Boolean,
|
|
||||||
colW: Dp,
|
|
||||||
modifier: GlanceModifier,
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val hidden = week.hiddenPerDay()
|
|
||||||
Box(modifier = modifier.fillMaxWidth()) {
|
|
||||||
// Day-open targets, one full-height strip per column, sitting *under* the
|
|
||||||
// content: a tap anywhere in a day still opens it, as in the app, without
|
|
||||||
// every cell in the column carrying a click target of its own. The content
|
|
||||||
// above is not clickable, so touches fall through to this layer; only an
|
|
||||||
// event bar opts out to open its own detail.
|
|
||||||
Row(modifier = GlanceModifier.fillMaxSize()) {
|
|
||||||
week.days.forEach { date ->
|
|
||||||
Box(
|
|
||||||
modifier = GlanceModifier
|
|
||||||
.width(colW)
|
|
||||||
.fillMaxHeight()
|
|
||||||
.clickable(openDayAction(context, date)),
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Column(modifier = GlanceModifier.fillMaxWidth()) {
|
|
||||||
// Day numbers. Fixed height so every week's number row lines up whether
|
|
||||||
// or not it holds today's larger pill.
|
|
||||||
Row(
|
|
||||||
modifier = GlanceModifier.fillMaxWidth().height(DAY_NUMBER_HEIGHT),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
week.days.forEach { date ->
|
|
||||||
DayNumber(
|
|
||||||
date = date,
|
|
||||||
isToday = date == today,
|
|
||||||
inMonth = date.month == currentMonth,
|
|
||||||
colW = colW,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(GlanceModifier.height(2.dp))
|
|
||||||
// One lane row per event row. A multi-day span is a single Box spanning
|
|
||||||
// its columns (colW * n) so it's connected with no seam and rounded ends.
|
|
||||||
// Lanes past the last one this week fills emit nothing at all.
|
|
||||||
repeat(week.usedLanes()) { lane ->
|
|
||||||
LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW)
|
|
||||||
Spacer(GlanceModifier.height(1.dp))
|
|
||||||
}
|
|
||||||
if (hidden.any { it > 0 }) OverflowRow(hidden = hidden, colW = colW)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How many lanes this week actually fills, counting from the top. The grid draws
|
|
||||||
* that many rows instead of a fixed [MAX_LANES]; an empty lane costs three views
|
|
||||||
* per day and most weeks leave the last two empty.
|
|
||||||
*/
|
|
||||||
private fun MonthWeek.usedLanes(): Int {
|
|
||||||
for (lane in MAX_LANES - 1 downTo 0) {
|
|
||||||
val filled = spans.any { it.lane == lane } ||
|
|
||||||
days.indices.any { col -> timedEventAt(this, lane, col, days[col]) != null }
|
|
||||||
if (filled) return lane + 1
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Events per day that no lane had room for — the "+N" counts, 0 where all fit. */
|
|
||||||
private fun MonthWeek.hiddenPerDay(): List<Int> = days.mapIndexed { col, date ->
|
|
||||||
val shownSpans = spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
|
||||||
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
|
|
||||||
val timedShown = minOf(freeSlots, timedByDay[date].orEmpty().size)
|
|
||||||
((countByDay[date] ?: 0) - shownSpans - timedShown).coerceAtLeast(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open [date]'s day view rooted in the month view (so back returns to the month
|
|
||||||
* grid) — the same target the in-app month grid uses when a day cell is tapped.
|
|
||||||
* Shared by every tappable part of a day column so, as in the app, a tap anywhere
|
|
||||||
* on a day opens it; only an event bar on top opts out to open its own detail.
|
|
||||||
*/
|
|
||||||
private fun openDayAction(context: Context, date: LocalDate) =
|
|
||||||
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month))
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A day's number. Every day but today is a bare centred [Text] carrying the column
|
|
||||||
* width itself — a wrapping Box costs three views, and there are 42 of these.
|
|
||||||
* Today keeps its filled circle, which does need the Box.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
|
||||||
val style = TextStyle(
|
|
||||||
color = when {
|
|
||||||
isToday -> GlanceTheme.colors.onPrimary
|
|
||||||
inMonth -> GlanceTheme.colors.onSurface
|
|
||||||
else -> GlanceTheme.colors.onSurfaceVariant
|
|
||||||
},
|
|
||||||
fontSize = 11.sp,
|
|
||||||
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
if (!isToday) {
|
|
||||||
Text(text = date.day.toString(), style = style, modifier = GlanceModifier.width(colW))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Box(modifier = GlanceModifier.width(colW), contentAlignment = Alignment.Center) {
|
|
||||||
Box(
|
|
||||||
modifier = GlanceModifier
|
|
||||||
.size(DAY_NUMBER_HEIGHT)
|
|
||||||
.cornerRadius(DAY_NUMBER_HEIGHT / 2)
|
|
||||||
.background(GlanceTheme.colors.primary),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Text(text = date.day.toString(), style = style)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A bar occupying [cols] columns, or — with a null event — that many blank ones. */
|
|
||||||
private data class LaneCell(val event: EventInstance?, val cols: Int)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A lane split into bars and the gaps between them, with consecutive blank columns
|
|
||||||
* merged into one gap: the week's day-open taps come from the strip underneath, so
|
|
||||||
* a gap needs no per-column view and a mostly-empty lane collapses to a spacer.
|
|
||||||
*/
|
|
||||||
private fun MonthWeek.laneCells(lane: Int): List<LaneCell> {
|
|
||||||
val cells = mutableListOf<LaneCell>()
|
|
||||||
var gap = 0
|
|
||||||
var col = 0
|
|
||||||
fun closeGap() {
|
|
||||||
if (gap > 0) cells += LaneCell(null, gap)
|
|
||||||
gap = 0
|
|
||||||
}
|
|
||||||
while (col < 7) {
|
|
||||||
val span = spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
|
|
||||||
val timed = if (span == null) timedEventAt(this, lane, col, days[col]) else null
|
|
||||||
when {
|
|
||||||
span != null -> {
|
|
||||||
closeGap()
|
|
||||||
cells += LaneCell(span.event, span.endCol - col + 1)
|
|
||||||
col = span.endCol + 1
|
|
||||||
}
|
|
||||||
timed != null -> {
|
|
||||||
closeGap()
|
|
||||||
cells += LaneCell(timed, 1)
|
|
||||||
col += 1
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
gap += 1
|
|
||||||
col += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
closeGap()
|
|
||||||
return cells
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, colW: Dp) {
|
|
||||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
|
||||||
week.laneCells(lane).forEach { cell ->
|
|
||||||
if (cell.event == null) {
|
|
||||||
Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT))
|
|
||||||
} else {
|
|
||||||
SpanBar(
|
|
||||||
event = cell.event,
|
|
||||||
dark = dark,
|
|
||||||
soften = soften,
|
|
||||||
width = colW * cell.cols,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A single connected, rounded event bar [width] wide with its clipped title. */
|
|
||||||
@Composable
|
|
||||||
private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width: Dp) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val fill = eventFill(event.color, dark, soften)
|
|
||||||
Box(
|
|
||||||
modifier = GlanceModifier
|
|
||||||
.width(width)
|
|
||||||
.height(LANE_HEIGHT)
|
|
||||||
.padding(horizontal = 1.dp)
|
|
||||||
// Tap an event bar to open its detail, rooted in the month view.
|
|
||||||
.clickable(
|
|
||||||
actionStartActivity(
|
|
||||||
MainActivity.openEventIntent(
|
|
||||||
context = context,
|
|
||||||
eventId = event.eventId,
|
|
||||||
beginMillis = event.start.toEpochMilliseconds(),
|
|
||||||
endMillis = event.end.toEpochMilliseconds(),
|
|
||||||
source = CalendarView.Month,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = GlanceModifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.cornerRadius(4.dp)
|
|
||||||
.background(fill),
|
|
||||||
contentAlignment = Alignment.CenterStart,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
|
|
||||||
maxLines = 1,
|
|
||||||
style = TextStyle(
|
|
||||||
color = ColorProvider(eventInk(fill)),
|
|
||||||
fontSize = 9.sp,
|
|
||||||
textDecoration = glanceDeclinedDecoration(event.isDeclined),
|
|
||||||
),
|
|
||||||
modifier = GlanceModifier.padding(horizontal = 3.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The "+N" row, drawn only for weeks that hide something, gaps merged as in a lane. */
|
|
||||||
@Composable
|
|
||||||
private fun OverflowRow(hidden: List<Int>, colW: Dp) {
|
|
||||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
|
||||||
var gap = 0
|
|
||||||
hidden.forEach { count ->
|
|
||||||
if (count == 0) {
|
|
||||||
gap += 1
|
|
||||||
return@forEach
|
|
||||||
}
|
|
||||||
if (gap > 0) {
|
|
||||||
Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT))
|
|
||||||
gap = 0
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = "+$count",
|
|
||||||
maxLines = 1,
|
|
||||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
|
|
||||||
modifier = GlanceModifier.width(colW).padding(start = 3.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (gap > 0) Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The timed single-day event that fills lane [lane] on day [col], if any. */
|
|
||||||
private fun timedEventAt(week: MonthWeek, lane: Int, col: Int, date: LocalDate): EventInstance? {
|
|
||||||
val occupied = week.spans
|
|
||||||
.filter { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
|
||||||
.map { it.lane }
|
|
||||||
.toSet()
|
|
||||||
val freeSlots = (0 until MAX_LANES).filter { it !in occupied }
|
|
||||||
val timed = week.timedByDay[date].orEmpty()
|
|
||||||
return timed.getOrNull(freeSlots.indexOf(lane))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PermissionMessage() {
|
private fun PermissionMessage() {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -605,6 +375,7 @@ private fun PermissionMessage() {
|
|||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
),
|
),
|
||||||
|
modifier = GlanceModifier.wallpaperBacking().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,9 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.month
|
package de.jeanlucmakiola.calendula.widget.month
|
||||||
|
|
||||||
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.WidgetRolloverScheduler
|
|
||||||
|
|
||||||
/**
|
/** 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)
|
|
||||||
WidgetRolloverScheduler.sync(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Last month widget removed. [WidgetRolloverScheduler.sync] cancels only if
|
|
||||||
* no agenda widget is left either.
|
|
||||||
*/
|
|
||||||
override fun onDisabled(context: Context) {
|
|
||||||
super.onDisabled(context)
|
|
||||||
WidgetRolloverScheduler.sync(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
override fun onUpdate(
|
|
||||||
context: Context,
|
|
||||||
appWidgetManager: AppWidgetManager,
|
|
||||||
appWidgetIds: IntArray,
|
|
||||||
) {
|
|
||||||
super.onUpdate(context, appWidgetManager, appWidgetIds)
|
|
||||||
WidgetRolloverScheduler.sync(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 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp" android:height="24dp"
|
|
||||||
android:viewportWidth="24" android:viewportHeight="24">
|
|
||||||
<path android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
|
|
||||||
</vector>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp" android:height="24dp"
|
|
||||||
android:viewportWidth="24" android:viewportHeight="24">
|
|
||||||
<path android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M19,3h-1V1h-2v2H8V1H6v2H5C3.89,3 3.01,3.9 3.01,5L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2zM19,19H5V8h14v11zM7,10h5v5H7z"/>
|
|
||||||
</vector>
|
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||||
|
<corners android:radius="4dp" />
|
||||||
|
<solid android:color="@android:color/white" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||||
|
<corners android:radius="8dp" />
|
||||||
|
<solid android:color="@android:color/white" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
|
||||||
|
<solid android:color="@android:color/white" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:paddingHorizontal="1dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/bar_bg"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_bar_bg" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/bar_title"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:paddingHorizontal="3dp"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
</FrameLayout>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="2"
|
||||||
|
android:paddingHorizontal="1dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/bar_bg"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_bar_bg" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/bar_title"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:paddingHorizontal="3dp"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
</FrameLayout>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="3"
|
||||||
|
android:paddingHorizontal="1dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/bar_bg"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_bar_bg" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/bar_title"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:paddingHorizontal="3dp"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
</FrameLayout>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="4"
|
||||||
|
android:paddingHorizontal="1dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/bar_bg"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_bar_bg" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/bar_title"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:paddingHorizontal="3dp"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
</FrameLayout>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="5"
|
||||||
|
android:paddingHorizontal="1dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/bar_bg"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_bar_bg" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/bar_title"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:paddingHorizontal="3dp"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
</FrameLayout>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="6"
|
||||||
|
android:paddingHorizontal="1dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/bar_bg"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_bar_bg" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/bar_title"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:paddingHorizontal="3dp"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
</FrameLayout>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="7"
|
||||||
|
android:paddingHorizontal="1dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/bar_bg"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_bar_bg" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/bar_title"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:paddingHorizontal="3dp"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
</FrameLayout>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="2" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="3" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="4" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="5" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="6" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="7" />
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/month_grid"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical" />
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/lane"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="15dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingBottom="1dp"
|
||||||
|
android:weightSum="7" />
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/more"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:paddingStart="3dp"
|
||||||
|
android:textSize="9sp" />
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/strip_0"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:foreground="?android:attr/selectableItemBackground"
|
||||||
|
android:padding="1dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/cell_0"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_cell_bg" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/cell_dim_0"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_cell_bg"
|
||||||
|
android:visibility="gone" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/strip_1"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:foreground="?android:attr/selectableItemBackground"
|
||||||
|
android:padding="1dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/cell_1"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_cell_bg" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/cell_dim_1"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_cell_bg"
|
||||||
|
android:visibility="gone" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/strip_2"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:foreground="?android:attr/selectableItemBackground"
|
||||||
|
android:padding="1dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/cell_2"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_cell_bg" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/cell_dim_2"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_cell_bg"
|
||||||
|
android:visibility="gone" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/strip_3"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:foreground="?android:attr/selectableItemBackground"
|
||||||
|
android:padding="1dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/cell_3"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_cell_bg" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/cell_dim_3"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_cell_bg"
|
||||||
|
android:visibility="gone" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/strip_4"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:foreground="?android:attr/selectableItemBackground"
|
||||||
|
android:padding="1dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/cell_4"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_cell_bg" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/cell_dim_4"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_cell_bg"
|
||||||
|
android:visibility="gone" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/strip_5"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:foreground="?android:attr/selectableItemBackground"
|
||||||
|
android:padding="1dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/cell_5"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_cell_bg" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/cell_dim_5"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_cell_bg"
|
||||||
|
android:visibility="gone" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/strip_6"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:foreground="?android:attr/selectableItemBackground"
|
||||||
|
android:padding="1dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/cell_6"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_cell_bg" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/cell_dim_6"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_cell_bg"
|
||||||
|
android:visibility="gone" />
|
||||||
|
</FrameLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/today_bg_0"
|
||||||
|
android:layout_width="18dp"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/widget_month_today_bg"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/day_0"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:gravity="center"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/today_bg_1"
|
||||||
|
android:layout_width="18dp"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/widget_month_today_bg"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/day_1"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:gravity="center"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/today_bg_2"
|
||||||
|
android:layout_width="18dp"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/widget_month_today_bg"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/day_2"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:gravity="center"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/today_bg_3"
|
||||||
|
android:layout_width="18dp"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/widget_month_today_bg"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/day_3"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:gravity="center"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/today_bg_4"
|
||||||
|
android:layout_width="18dp"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/widget_month_today_bg"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/day_4"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:gravity="center"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/today_bg_5"
|
||||||
|
android:layout_width="18dp"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/widget_month_today_bg"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/day_5"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:gravity="center"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/today_bg_6"
|
||||||
|
android:layout_width="18dp"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/widget_month_today_bg"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/day_6"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:gravity="center"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
</FrameLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/week_lanes"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingTop="2dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
@@ -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"
|
||||||
|
tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessParent">
|
||||||
|
|
||||||
<TextView
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
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="@string/widget_agenda_title"
|
android:text="@string/widget_agenda_title"
|
||||||
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 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
|
||||||
|
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
|
<TextView
|
||||||
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:ellipsize="end"
|
||||||
android:text="@string/agenda_header_today"
|
android:maxLines="1"
|
||||||
android:textColor="@color/widget_preview_primary"
|
android:text="@string/settings_past_events_sample_morning"
|
||||||
|
android:textColor="@color/widget_preview_on_event_light"
|
||||||
android:textSize="12sp"
|
android:textSize="12sp"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="10dp"
|
|
||||||
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="#4F7CAC" />
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="10dp"
|
|
||||||
android:orientation="vertical">
|
|
||||||
<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="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
|
<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="@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
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="10dp"
|
android:layout_marginStart="6dp"
|
||||||
|
android:layout_weight="1"
|
||||||
android:orientation="vertical">
|
android:orientation="vertical">
|
||||||
<TextView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="Design review"
|
|
||||||
android:textColor="@color/widget_preview_on_surface"
|
|
||||||
android:textSize="14sp" />
|
|
||||||
<TextView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="11:30 – 12:30"
|
|
||||||
android:textColor="@color/widget_preview_variant"
|
|
||||||
android:textSize="12sp" />
|
|
||||||
</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="0dp"
|
||||||
android:orientation="horizontal"
|
android:background="@drawable/preview_widget_event_bg"
|
||||||
android:gravity="center_vertical">
|
android:backgroundTint="@color/widget_preview_event_orange"
|
||||||
<ImageView
|
android:orientation="vertical"
|
||||||
android:layout_width="5dp"
|
android:paddingHorizontal="10dp"
|
||||||
android:layout_height="34dp"
|
android:paddingVertical="3dp">
|
||||||
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
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="1:1 with Mara"
|
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="14:00 – 14: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>
|
</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"
|
||||||
|
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="28"
|
||||||
|
android:textColor="@color/widget_preview_on_surface"
|
||||||
|
android:textSize="10sp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
<LinearLayout
|
||||||
<!-- Week 2: 8..14 (bars on 11,12,13) -->
|
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="8" 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="9" 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="10" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
android:background="@drawable/preview_widget_outside_bg"
|
||||||
<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_horizontal"
|
||||||
<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:orientation="vertical">
|
||||||
<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" />
|
<TextView
|
||||||
<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: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>
|
||||||
|
<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="30"
|
||||||
|
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" />
|
<LinearLayout
|
||||||
<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" />
|
android:layout_width="0dp"
|
||||||
<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" />
|
android:layout_height="match_parent"
|
||||||
<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" />
|
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>
|
||||||
|
<LinearLayout
|
||||||
<!-- Week 4: 22..28 (bar on 24) -->
|
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="22" 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="23" 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="24" android:textColor="#C58A56" android:textStyle="bold" android:textSize="12sp" />
|
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="25" android:textColor="@color/widget_preview_on_surface" android:textSize="12sp" />
|
android:gravity="center_horizontal"
|
||||||
<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:orientation="vertical">
|
||||||
<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" />
|
<TextView
|
||||||
<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_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>
|
||||||
|
<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="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
|
||||||
|
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="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>
|
||||||
|
|
||||||
<!-- Week 5: 29, 30, then trailing July days greyed -->
|
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal" android:layout_marginTop="4dp">
|
|
||||||
<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" />
|
|
||||||
<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" />
|
|
||||||
<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" />
|
|
||||||
<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" />
|
|
||||||
<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" />
|
|
||||||
<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" />
|
|
||||||
<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" />
|
|
||||||
</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,12 +435,94 @@
|
|||||||
<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>
|
||||||
<string name="widget_prev_month">Previous month</string>
|
<string name="widget_prev_month">Previous month</string>
|
||||||
<string name="widget_next_month">Next month</string>
|
<string name="widget_next_month">Next month</string>
|
||||||
<string name="widget_today">Today</string>
|
<string name="widget_today">Today</string>
|
||||||
|
<string name="widget_config_background">Background</string>
|
||||||
|
<string name="widget_config_background_solid">Solid</string>
|
||||||
|
<string name="widget_config_background_tinted">Tinted</string>
|
||||||
|
<string name="widget_config_background_transparent">Transparent</string>
|
||||||
|
<string name="widget_config_theme_follow_app">Follow app</string>
|
||||||
|
<string name="widget_config_content">Content</string>
|
||||||
|
<string name="widget_config_range">Time range</string>
|
||||||
|
<string name="widget_config_text_size">Text size</string>
|
||||||
|
<string name="widget_config_load_failed">Couldn\'t read this widget\'s settings.</string>
|
||||||
|
<string name="widget_config_preview_failed">Preview unavailable</string>
|
||||||
|
<string name="widget_config_save_failed">Couldn\'t save the widget. Try again.</string>
|
||||||
|
<!-- A widget's name and its number, when there are several of the same kind: "Calendula agenda 2" -->
|
||||||
|
<string name="widget_numbered">%1$s %2$d</string>
|
||||||
|
<!-- A placed widget's background and theme: "Tinted · Follow app" -->
|
||||||
|
<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>
|
||||||
@@ -539,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>
|
||||||
@@ -554,7 +639,7 @@
|
|||||||
<!-- %1$d is the event text size as a percentage of the smallest step, e.g. 130. -->
|
<!-- %1$d is the event text size as a percentage of the smallest step, e.g. 130. -->
|
||||||
<string name="settings_widget_size_summary">Event text %1$d%%</string>
|
<string name="settings_widget_size_summary">Event text %1$d%%</string>
|
||||||
<string name="settings_agenda_show_today">Always show today</string>
|
<string name="settings_agenda_show_today">Always show today</string>
|
||||||
<string name="settings_agenda_show_today_hint">Keep today at the top of the agenda and its widget, even once nothing is left today.</string>
|
<string name="settings_agenda_show_today_hint">Keep today at the top of the agenda, even once nothing is left today. Each widget has its own setting.</string>
|
||||||
<string name="settings_agenda_range_bar">Range bar</string>
|
<string name="settings_agenda_range_bar">Range bar</string>
|
||||||
<string name="settings_agenda_range_bar_hint">Show a bar at the top of the agenda naming the dates shown, with a button to switch the range for the session</string>
|
<string name="settings_agenda_range_bar_hint">Show a bar at the top of the agenda naming the dates shown, with a button to switch the range for the session</string>
|
||||||
<string name="agenda_range_day">Today</string>
|
<string name="agenda_range_day">Today</string>
|
||||||
@@ -645,12 +730,15 @@
|
|||||||
<string name="settings_event_form_subtitle">Default fields and behaviour</string>
|
<string name="settings_event_form_subtitle">Default fields and behaviour</string>
|
||||||
<string name="settings_notifications_subtitle">Reminders and delivery</string>
|
<string name="settings_notifications_subtitle">Reminders and delivery</string>
|
||||||
<string name="settings_special_dates_subtitle">Contact birthdays & anniversaries</string>
|
<string name="settings_special_dates_subtitle">Contact birthdays & anniversaries</string>
|
||||||
<string name="settings_widgets_subtitle">Agenda widget, Quick Settings tile</string>
|
<string name="settings_widgets_subtitle">Your widgets, Quick Settings tile</string>
|
||||||
<string name="settings_backup_subtitle">Export, import, automatic backup</string>
|
<string name="settings_backup_subtitle">Export, import, automatic backup</string>
|
||||||
|
|
||||||
<!-- Widgets & tiles (#69) -->
|
<!-- Widgets & tiles (#69) -->
|
||||||
<string name="settings_section_widgets">Widgets & tiles</string>
|
<string name="settings_section_widgets">Widgets & tiles</string>
|
||||||
<string name="settings_widgets_hint">Settings for the home-screen widgets and the Quick Settings tile. Add a widget by long-pressing your home screen.</string>
|
<string name="settings_widgets_hint">Each widget has its own look and options. Tap one below, or long-press it on your home screen and choose Edit. Add a widget by long-pressing your home screen.</string>
|
||||||
|
<string name="settings_widgets_hint_legacy">Each widget has its own look and options. Tap one below to change it. Add a widget by long-pressing your home screen.</string>
|
||||||
|
<string name="settings_widgets_placed">On your home screen</string>
|
||||||
|
<string name="settings_widgets_none">No Calendula widgets on your home screen yet.</string>
|
||||||
|
|
||||||
<!-- Backup & restore (#69) — the rows themselves reuse the calendars_* strings -->
|
<!-- Backup & restore (#69) — the rows themselves reuse the calendars_* strings -->
|
||||||
<string name="settings_section_backup">Backup & restore</string>
|
<string name="settings_section_backup">Backup & restore</string>
|
||||||
|
|||||||
@@ -7,4 +7,10 @@
|
|||||||
<item name="android:windowBackground">@color/window_background</item>
|
<item name="android:windowBackground">@color/window_background</item>
|
||||||
<item name="android:windowLightStatusBar">true</item>
|
<item name="android:windowLightStatusBar">true</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<!-- Widget config: drawn over the wallpaper, so the preview shows the real home screen. -->
|
||||||
|
<style name="Theme.Calendula.WidgetConfig">
|
||||||
|
<item name="android:windowBackground">@android:color/transparent</item>
|
||||||
|
<item name="android:windowShowWallpaper">true</item>
|
||||||
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
android:minResizeHeight="110dp"
|
android:minResizeHeight="110dp"
|
||||||
android:resizeMode="horizontal|vertical"
|
android:resizeMode="horizontal|vertical"
|
||||||
android:widgetCategory="home_screen"
|
android:widgetCategory="home_screen"
|
||||||
|
android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity"
|
||||||
|
android:widgetFeatures="reconfigurable"
|
||||||
android:description="@string/widget_agenda_label"
|
android:description="@string/widget_agenda_label"
|
||||||
android:updatePeriodMillis="1800000"
|
android:updatePeriodMillis="1800000"
|
||||||
android:previewLayout="@layout/widget_preview_agenda"
|
android:previewLayout="@layout/widget_preview_agenda"
|
||||||
|
|||||||
@@ -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" />
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
android:minResizeHeight="150dp"
|
android:minResizeHeight="150dp"
|
||||||
android:resizeMode="horizontal|vertical"
|
android:resizeMode="horizontal|vertical"
|
||||||
android:widgetCategory="home_screen"
|
android:widgetCategory="home_screen"
|
||||||
|
android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity"
|
||||||
|
android:widgetFeatures="reconfigurable"
|
||||||
android:description="@string/widget_month_label"
|
android:description="@string/widget_month_label"
|
||||||
android:updatePeriodMillis="1800000"
|
android:updatePeriodMillis="1800000"
|
||||||
android:previewLayout="@layout/widget_preview_month"
|
android:previewLayout="@layout/widget_preview_month"
|
||||||
|
|||||||
@@ -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" />
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user