Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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"
|
||||||
@@ -343,12 +353,13 @@
|
|||||||
</receiver>
|
</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 +376,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,8 @@ 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.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 +44,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()
|
||||||
@@ -58,7 +61,7 @@ class CalendulaApp : Application() {
|
|||||||
*/
|
*/
|
||||||
private fun reconcileWidgetRollover() {
|
private fun reconcileWidgetRollover() {
|
||||||
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||||
WidgetRolloverScheduler.sync(this@CalendulaApp)
|
syncWidgetRollover(this@CalendulaApp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ interface CalendarRepository {
|
|||||||
|
|
||||||
/** 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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,47 @@ 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.config.WidgetKind
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import de.jeanlucmakiola.calendula.widget.config.backgroundLabel
|
||||||
|
import de.jeanlucmakiola.calendula.widget.config.label
|
||||||
|
import de.jeanlucmakiola.calendula.widget.config.placedWidgets
|
||||||
|
import de.jeanlucmakiola.calendula.widget.config.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 +69,44 @@ internal fun WidgetsScreen(
|
|||||||
QuickSettingsTileRow()
|
QuickSettingsTileRow()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (showAgendaWidgetRange) {
|
@Composable
|
||||||
AgendaRangePicker(
|
private fun PlacedWidgetRows() {
|
||||||
title = stringResource(R.string.settings_agenda_widget_range),
|
val context = LocalContext.current
|
||||||
description = stringResource(R.string.settings_agenda_widget_range_hint),
|
// Read on every resume, the first included, so a widget added, removed or
|
||||||
selected = state.agendaWidgetRange,
|
// edited meanwhile shows up.
|
||||||
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
|
var refresh by remember { mutableIntStateOf(0) }
|
||||||
onSelect = viewModel::setAgendaWidgetRange,
|
LifecycleResumeEffect(Unit) {
|
||||||
onDismiss = { showAgendaWidgetRange = false },
|
refresh++
|
||||||
)
|
onPauseOrDispose {}
|
||||||
}
|
}
|
||||||
if (showWidgetSize) {
|
val widgets by produceState<List<PlacedWidget>?>(initialValue = null, refresh) {
|
||||||
OptionPicker(
|
if (refresh > 0) value = withContext(Dispatchers.IO) { context.placedWidgets() }
|
||||||
title = stringResource(R.string.settings_widget_size),
|
}
|
||||||
header = { GroupedHint(stringResource(R.string.settings_widget_size_hint), verticalPadding = 8.dp) },
|
val placed = widgets ?: return
|
||||||
predictiveBack = true,
|
if (placed.isEmpty()) {
|
||||||
options = WidgetSize.entries,
|
GroupedRow(
|
||||||
selected = state.widgetSize,
|
title = stringResource(R.string.settings_widgets_none),
|
||||||
label = { widgetSizeLabel(it) },
|
position = Position.Alone,
|
||||||
// No live preview: the widget is Glance/RemoteViews, so a Compose
|
dimmed = true,
|
||||||
// mock-up would be a second implementation free to drift.
|
)
|
||||||
summary = { widgetSizeSummary(it) },
|
return
|
||||||
onSelect = viewModel::setWidgetSize,
|
}
|
||||||
onDismiss = { showWidgetSize = false },
|
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 +138,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,88 @@
|
|||||||
|
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.glance.GlanceId
|
||||||
|
import androidx.glance.appwidget.state.getAppWidgetState
|
||||||
|
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
|
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")
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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,7 +45,54 @@ 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()
|
||||||
|
|
||||||
@@ -51,112 +109,72 @@ 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 options = widgetState(id).agendaWidgetOptions(settings.agendaDefaults)
|
||||||
* [invalidateMonthWidgetCache] when calendar data changes (the freshness
|
return Themed(loadAgendaWidgetData(settings, options), settings.themeMode)
|
||||||
* receiver), and automatically when the day rolls over (the `today` guard).
|
|
||||||
*/
|
|
||||||
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,
|
||||||
|
): 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()
|
||||||
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 +183,50 @@ 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).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): Themed<MonthWidgetSource> {
|
||||||
|
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
|
||||||
|
return Themed(queryMonthSource(centerIndex, settings), settings.themeMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun Context.queryMonthSource(centerIndex: Int, settings: WidgetSettings): 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 +235,11 @@ 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(),
|
||||||
months = months,
|
months = months,
|
||||||
).also { MonthWidgetCache.data = it }
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,137 @@
|
|||||||
|
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.calendula.widget.agenda.AgendaWidget
|
||||||
|
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
|
||||||
|
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||||
|
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetRefresher
|
||||||
|
import de.jeanlucmakiola.floret.glance.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<*>> = listOf(
|
||||||
|
MonthWidgetReceiver::class.java,
|
||||||
|
AgendaWidgetReceiver::class.java,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 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 both widgets key their data load on it (#420).
|
||||||
|
*/
|
||||||
|
internal suspend fun redrawWidgets(context: Context) {
|
||||||
|
redrawAgendaWidgets(context)
|
||||||
|
redrawMonthWidgets(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun redrawAgendaWidgets(context: Context) =
|
||||||
|
AgendaWidget().updatePlaced(context, AgendaWidgetReceiver::class.java, bumpRefreshStamp = true)
|
||||||
|
|
||||||
|
/** Redraws only the agenda widgets with no options of their own, which still follow the app's. */
|
||||||
|
private suspend fun redrawUnconfiguredAgendaWidgets(context: Context) {
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
val ids = AppWidgetManager.getInstance(appContext)
|
||||||
|
?.getAppWidgetIds(ComponentName(appContext, AgendaWidgetReceiver::class.java)) ?: return
|
||||||
|
val glanceManager = GlanceAppWidgetManager(appContext)
|
||||||
|
ids.forEach { appWidgetId ->
|
||||||
|
val glanceId = glanceManager.getGlanceIdBy(appWidgetId)
|
||||||
|
if (!appContext.widgetState(glanceId).hasAgendaOptions) AgendaWidget().updateWithRefresh(appContext, glanceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun redrawMonthWidgets(context: Context) =
|
||||||
|
MonthWidget().updatePlaced(context, MonthWidgetReceiver::class.java, bumpRefreshStamp = true)
|
||||||
|
|
||||||
|
/** When the app last saw a calendar change of its own, for skipping the `PROVIDER_CHANGED` that echoes it. */
|
||||||
|
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
|
||||||
|
* agenda widgets that have none 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 agenda = WidgetRefresher(scope) { redrawAgendaWidgets(context) }
|
||||||
|
val unconfiguredAgenda = WidgetRefresher(scope) { redrawUnconfiguredAgendaWidgets(context) }
|
||||||
|
val month = WidgetRefresher(scope) { redrawMonthWidgets(context) }
|
||||||
|
watch(repository.localChanges) {
|
||||||
|
lastLocalChange.set(SystemClock.elapsedRealtime())
|
||||||
|
agenda.request()
|
||||||
|
month.request()
|
||||||
|
}
|
||||||
|
watch(prefs.widgetSettings.map { it.shared }.distinctUntilChanged().drop(1)) {
|
||||||
|
agenda.request()
|
||||||
|
month.request()
|
||||||
|
}
|
||||||
|
watch(prefs.widgetSettings.map { it.agendaDefaults }.distinctUntilChanged().drop(1)) {
|
||||||
|
unconfiguredAgenda.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. */
|
||||||
|
|||||||
@@ -3,38 +3,22 @@ package de.jeanlucmakiola.calendula.widget.agenda
|
|||||||
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 +31,64 @@ 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.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.formatCalendarTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.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.loadAgendaWidget
|
||||||
|
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||||
|
import de.jeanlucmakiola.floret.glance.WidgetGroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.glance.themedColor
|
||||||
|
import de.jeanlucmakiola.floret.glance.wallpaperBacking
|
||||||
|
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.TimeZone
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toJavaDayOfWeek
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Instant
|
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() {
|
||||||
@@ -132,31 +102,41 @@ class AgendaWidget : GlanceAppWidget() {
|
|||||||
override val sizeMode = SizeMode.Single
|
override val sizeMode = SizeMode.Single
|
||||||
|
|
||||||
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 +146,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 +179,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 +200,230 @@ 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)
|
||||||
// Bound the payload, then drop a day header the cut left
|
// Each day is one card of grouped rows (#71), with its date pill
|
||||||
// stranded with nothing under it.
|
// beside the first row.
|
||||||
.take(MAX_AGENDA_ROWS)
|
val positions = dayPositions(rows)
|
||||||
.dropLastWhile { it is AgendaRow.Header }
|
val cardColor = agendaCardColor()
|
||||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
val item: @Composable (Int) -> Unit = { index ->
|
||||||
items(rows.size) { index ->
|
val row = rows[index]
|
||||||
when (val row = rows[index]) {
|
val position = positions[index]
|
||||||
is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics)
|
val first = position == Position.Top || position == Position.Alone
|
||||||
is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics)
|
val dayRow: @Composable (GlanceModifier) -> Unit = { modifier ->
|
||||||
is AgendaRow.Event -> EventRow(
|
Row(modifier = modifier.fillMaxWidth()) {
|
||||||
event = row.event,
|
Box(modifier = GlanceModifier.width(metrics.pillWidth)) {
|
||||||
day = row.date,
|
if (first) DatePill(row.date, data.today, cardColor, metrics)
|
||||||
dark = dark,
|
}
|
||||||
soften = data.soften,
|
Spacer(GlanceModifier.width(GUTTER_GAP))
|
||||||
is24Hour = data.is24Hour,
|
Box(modifier = GlanceModifier.defaultWeight()) {
|
||||||
dimmed = pastDisplay == PastEventDisplay.DIM &&
|
val rowColor = when (row) {
|
||||||
row.event.hasEnded(data.now),
|
is AgendaRow.Event -> row.fill(data).let { themedColor(day = it.day, night = it.night) }
|
||||||
metrics = metrics,
|
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()) {
|
||||||
|
items(rows.size) { item(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
@Composable
|
||||||
private fun AgendaHeader(metrics: AgendaMetrics) {
|
private fun AgendaRowContent(row: AgendaRow, data: AgendaWidgetData.Ready, metrics: AgendaMetrics) {
|
||||||
val context = androidx.glance.LocalContext.current
|
when (row) {
|
||||||
Row(
|
is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics)
|
||||||
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
|
is AgendaRow.Event -> EventRow(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
event = row.event,
|
||||||
) {
|
day = row.date,
|
||||||
// Tap the "Upcoming" title to open the app on the user's default view
|
fill = row.fill(data),
|
||||||
// (issue #20) — a null target resolves to the default home in the host.
|
is24Hour = data.is24Hour,
|
||||||
Text(
|
|
||||||
text = context.getString(R.string.widget_agenda_title),
|
|
||||||
style = TextStyle(
|
|
||||||
color = GlanceTheme.colors.primary,
|
|
||||||
fontSize = metrics.title,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
),
|
|
||||||
modifier = GlanceModifier
|
|
||||||
.defaultWeight()
|
|
||||||
.clickable(
|
|
||||||
actionStartActivity(MainActivity.openViewIntent(context, view = null)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
IconButton(
|
|
||||||
resId = R.drawable.ic_widget_refresh,
|
|
||||||
contentDescription = context.getString(R.string.widget_refresh),
|
|
||||||
onClick = GlanceModifier.clickable(actionRunCallback<RefreshAgendaAction>()),
|
|
||||||
metrics = metrics,
|
metrics = metrics,
|
||||||
)
|
)
|
||||||
IconButton(
|
}
|
||||||
resId = R.drawable.ic_widget_add,
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AgendaHeader() {
|
||||||
|
val context = androidx.glance.LocalContext.current
|
||||||
|
// Tap the "Upcoming" title to open the app on the user's default view
|
||||||
|
// (issue #20) — a null target resolves to the default home in the host.
|
||||||
|
WidgetHeader(
|
||||||
|
title = context.getString(R.string.widget_agenda_title),
|
||||||
|
titleAction = actionStartActivity(MainActivity.openViewIntent(context, view = null)),
|
||||||
|
titleInset = 4.dp,
|
||||||
|
) {
|
||||||
|
WidgetHeaderAction(
|
||||||
|
icon = ImageProvider(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,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Cards sit one tone off the root fill, and stay opaque over a wallpaper (#292). */
|
||||||
@Composable
|
@Composable
|
||||||
private fun IconButton(
|
private fun agendaCardColor(): ColorProvider = when (LocalWidgetBackground.current) {
|
||||||
resId: Int,
|
WidgetBackground.SOLID -> GlanceTheme.colors.surfaceVariant
|
||||||
contentDescription: String,
|
WidgetBackground.TINTED, WidgetBackground.TRANSPARENT -> GlanceTheme.colors.widgetBackground
|
||||||
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The month name ("November", with its year outside the current one) above a new month's first day. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun DayHeaderRow(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) {
|
private fun MonthLabel(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,30 +437,21 @@ private fun EventRow(
|
|||||||
),
|
),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Box(
|
Text(
|
||||||
modifier = GlanceModifier
|
text = title,
|
||||||
.width(STRIPE_WIDTH)
|
maxLines = 1,
|
||||||
.height(metrics.stripeH)
|
style = TextStyle(
|
||||||
.cornerRadius(3.dp)
|
color = titleInk,
|
||||||
.background(stripeColor),
|
fontSize = metrics.eventTitle,
|
||||||
) {}
|
fontWeight = FontWeight.Medium,
|
||||||
Spacer(GlanceModifier.width(STRIPE_GAP))
|
textDecoration = glanceDeclinedDecoration(event.isDeclined),
|
||||||
Column(modifier = GlanceModifier.defaultWeight()) {
|
),
|
||||||
Text(
|
)
|
||||||
text = title,
|
Text(
|
||||||
maxLines = 1,
|
text = eventTimeSummary(context, event, day, is24Hour),
|
||||||
style = TextStyle(
|
maxLines = 1,
|
||||||
color = titleColor,
|
style = TextStyle(color = timeInk, fontSize = metrics.eventTime),
|
||||||
fontSize = metrics.eventTitle,
|
)
|
||||||
textDecoration = glanceDeclinedDecoration(event.isDeclined),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = eventTimeSummary(context, event, day, is24Hour),
|
|
||||||
maxLines = 1,
|
|
||||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.eventTime),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,6 +465,7 @@ 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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -501,6 +487,16 @@ 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,
|
||||||
|
|||||||
+16
-6
@@ -1,10 +1,13 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.agenda
|
package de.jeanlucmakiola.calendula.widget.agenda
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.appwidget.AppWidgetManager
|
import android.appwidget.AppWidgetManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.glance.appwidget.GlanceAppWidget
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
|
import de.jeanlucmakiola.calendula.widget.syncWidgetRollover
|
||||||
|
import de.jeanlucmakiola.floret.glance.reloadOnUpdate
|
||||||
|
import de.jeanlucmakiola.floret.glance.restoreState
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Host-facing receiver for the agenda widget. Declared in the manifest with the
|
* Host-facing receiver for the agenda widget. Declared in the manifest with the
|
||||||
@@ -17,28 +20,35 @@ class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
|
|||||||
/** First agenda widget placed — start rolling "today" over at midnight (#228). */
|
/** First agenda widget placed — start rolling "today" over at midnight (#228). */
|
||||||
override fun onEnabled(context: Context) {
|
override fun onEnabled(context: Context) {
|
||||||
super.onEnabled(context)
|
super.onEnabled(context)
|
||||||
WidgetRolloverScheduler.sync(context)
|
syncWidgetRollover(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Last agenda widget removed. [WidgetRolloverScheduler.sync] cancels only if
|
* Last agenda widget removed. [syncWidgetRollover] cancels only if
|
||||||
* no month widget is left either.
|
* no month widget is left either.
|
||||||
*/
|
*/
|
||||||
override fun onDisabled(context: Context) {
|
override fun onDisabled(context: Context) {
|
||||||
super.onDisabled(context)
|
super.onDisabled(context)
|
||||||
WidgetRolloverScheduler.sync(context)
|
syncWidgetRollover(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A backup restore or device transfer renumbered the widgets: keep each one's settings (#292). */
|
||||||
|
override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) {
|
||||||
|
restoreState(context, oldWidgetIds, newWidgetIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Self-heal on the system's own `updatePeriodMillis` wake-up — see
|
* Self-heal on the system's own `updatePeriodMillis` wake-up — see
|
||||||
* [de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver.onUpdate].
|
* [de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver.onUpdate].
|
||||||
*/
|
*/
|
||||||
|
@SuppressLint("MissingSuperCall")
|
||||||
override fun onUpdate(
|
override fun onUpdate(
|
||||||
context: Context,
|
context: Context,
|
||||||
appWidgetManager: AppWidgetManager,
|
appWidgetManager: AppWidgetManager,
|
||||||
appWidgetIds: IntArray,
|
appWidgetIds: IntArray,
|
||||||
) {
|
) {
|
||||||
super.onUpdate(context, appWidgetManager, appWidgetIds)
|
// In place of super, which only recomposes and would take the goAsync handle.
|
||||||
WidgetRolloverScheduler.sync(context)
|
reloadOnUpdate(context, appWidgetIds)
|
||||||
|
syncWidgetRollover(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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.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,130 @@
|
|||||||
|
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,
|
||||||
|
onAgendaOptions = viewModel::setAgendaOptions,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,328 @@
|
|||||||
|
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.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.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.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,
|
||||||
|
onAgendaOptions: (AgendaWidgetOptions) -> 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ConfigOptions(
|
||||||
|
state = state,
|
||||||
|
onAppearance = onAppearance,
|
||||||
|
onAgendaOptions = onAgendaOptions,
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal val WidgetKind.label: Int
|
||||||
|
get() = when (this) {
|
||||||
|
WidgetKind.AGENDA -> R.string.widget_agenda_label
|
||||||
|
WidgetKind.MONTH -> R.string.widget_month_label
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ConfigOptions(
|
||||||
|
state: WidgetConfigUiState.Ready,
|
||||||
|
onAppearance: (WidgetAppearance) -> Unit,
|
||||||
|
onAgendaOptions: (AgendaWidgetOptions) -> 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,
|
||||||
|
)
|
||||||
|
state.agenda?.let { options ->
|
||||||
|
AgendaOptions(options = options, state = state, onChange = onAgendaOptions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AgendaOptions(
|
||||||
|
options: AgendaWidgetOptions,
|
||||||
|
state: WidgetConfigUiState.Ready,
|
||||||
|
onChange: (AgendaWidgetOptions) -> Unit,
|
||||||
|
) {
|
||||||
|
var showRange by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var showSize by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var showPast by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
GroupedSectionHeader(stringResource(R.string.widget_config_content))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.widget_config_range),
|
||||||
|
summary = agendaRangeLabel(options.range),
|
||||||
|
position = Position.Top,
|
||||||
|
onClick = { showRange = true },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.widget_config_text_size),
|
||||||
|
summary = widgetSizeLabel(options.textSize),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { showSize = true },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_past_events),
|
||||||
|
summary = stringResource(pastEventDisplayLabelRes(options.pastDisplay)),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { showPast = true },
|
||||||
|
)
|
||||||
|
GroupedSwitchRow(
|
||||||
|
title = stringResource(R.string.settings_agenda_show_today),
|
||||||
|
checked = options.showToday,
|
||||||
|
onCheckedChange = { onChange(options.copy(showToday = it)) },
|
||||||
|
position = Position.Bottom,
|
||||||
|
)
|
||||||
|
|
||||||
|
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,242 @@
|
|||||||
|
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.GlanceAppWidget
|
||||||
|
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.prefs.SettingsPrefs
|
||||||
|
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.agenda.AgendaWidget
|
||||||
|
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
|
||||||
|
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
|
||||||
|
import de.jeanlucmakiola.calendula.widget.loadThemedMonthSource
|
||||||
|
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||||
|
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
|
||||||
|
import de.jeanlucmakiola.calendula.widget.monthIndexOf
|
||||||
|
import de.jeanlucmakiola.calendula.widget.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 java.util.Locale
|
||||||
|
|
||||||
|
/** The widgets a config screen can edit, told apart by their receiver. */
|
||||||
|
enum class WidgetKind(val receiver: Class<*>) {
|
||||||
|
AGENDA(AgendaWidgetReceiver::class.java),
|
||||||
|
MONTH(MonthWidgetReceiver::class.java),
|
||||||
|
;
|
||||||
|
|
||||||
|
fun widget(): GlanceAppWidget = when (this) {
|
||||||
|
AGENDA -> AgendaWidget()
|
||||||
|
MONTH -> MonthWidget()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun of(context: Context, appWidgetId: Int): WidgetKind? {
|
||||||
|
val provider = AppWidgetManager.getInstance(context)?.getAppWidgetInfo(appWidgetId)?.provider ?: return null
|
||||||
|
return entries.firstOrNull { it.receiver.name == provider.className }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the preview draws, loaded with the draft's options. */
|
||||||
|
sealed interface WidgetPreviewData {
|
||||||
|
data class Agenda(val data: AgendaWidgetData) : WidgetPreviewData
|
||||||
|
data class Month(val source: MonthWidgetSource, val index: Int) : WidgetPreviewData
|
||||||
|
}
|
||||||
|
|
||||||
|
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 appearance: WidgetAppearance,
|
||||||
|
/** Null for a widget with no content options. */
|
||||||
|
val agenda: AgendaWidgetOptions?,
|
||||||
|
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?,
|
||||||
|
/** The first preview load failed, so there's nothing to show. */
|
||||||
|
val previewFailed: Boolean = false,
|
||||||
|
val saving: Boolean = false,
|
||||||
|
) : WidgetConfigUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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,
|
||||||
|
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,
|
||||||
|
appearance = stored.widgetAppearance,
|
||||||
|
agenda = if (kind == WidgetKind.AGENDA) stored.agendaWidgetOptions(settings.agendaDefaults) else null,
|
||||||
|
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 when (kind) {
|
||||||
|
WidgetKind.AGENDA -> DpSize(260.dp, 280.dp)
|
||||||
|
WidgetKind.MONTH -> DpSize(320.dp, 340.dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAppearance(appearance: WidgetAppearance) = edit { it.copy(appearance = appearance) }
|
||||||
|
|
||||||
|
fun setAgendaOptions(options: AgendaWidgetOptions) {
|
||||||
|
edit { it.copy(agenda = options) }
|
||||||
|
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 = when (ready.kind) {
|
||||||
|
WidgetKind.AGENDA -> {
|
||||||
|
val settings = prefs.widgetSettings.first()
|
||||||
|
WidgetPreviewData.Agenda(context.loadAgendaWidgetData(settings, requireNotNull(ready.agenda)))
|
||||||
|
}
|
||||||
|
WidgetKind.MONTH -> {
|
||||||
|
val index = monthIndexOf(today(systemZone()))
|
||||||
|
WidgetPreviewData.Month(context.loadThemedMonthSource(monthWindowCenter(index)).content, index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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.appearance)
|
||||||
|
ready.agenda?.let { state.putAgendaOptions(it) }
|
||||||
|
}
|
||||||
|
_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,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,47 +1,43 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.month
|
package de.jeanlucmakiola.calendula.widget.month
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
|
||||||
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.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
|
||||||
@@ -49,28 +45,37 @@ 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.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 +88,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 +108,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 +121,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,27 +131,55 @@ 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 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Step the displayed month by the `delta` action parameter (±1).
|
* Step the displayed month by the `delta` action parameter (±1).
|
||||||
*
|
*
|
||||||
@@ -179,57 +216,55 @@ 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
|
|
||||||
// live session, where provideGlance doesn't run again.
|
|
||||||
val context = LocalContext.current
|
|
||||||
var loaded by remember(source) { mutableStateOf<MonthWidgetSource.Ready>(source) }
|
|
||||||
LaunchedEffect(index) {
|
|
||||||
if (index !in loaded.months) {
|
|
||||||
(context.loadMonthWidgetSource(index) as? MonthWidgetSource.Ready)
|
|
||||||
?.let { loaded = it }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Column width from the live widget size, minus our H padding.
|
// Column width from the live widget size, minus our H padding.
|
||||||
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
|
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
|
||||||
val weeks = layoutMonthWeeks(ym, loaded.weekStart, loaded.instances, zone)
|
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
|
||||||
|
val context = LocalContext.current
|
||||||
|
val fontScale = context.resources.configuration.fontScale
|
||||||
|
val weekHeight = (LocalSize.current.height - gridChromeHeight(fontScale)) / weeks.size
|
||||||
|
val layouts = fitWeeks(weeks, lanesFitting(weekHeight))
|
||||||
|
val mode = LocalWidgetThemeMode.current
|
||||||
|
val colors = GridColors(
|
||||||
|
onSurface = remoteColor(WidgetColorRole.ON_SURFACE),
|
||||||
|
onSurfaceVariant = remoteColor(WidgetColorRole.ON_SURFACE_VARIANT),
|
||||||
|
primary = remoteColor(WidgetColorRole.PRIMARY),
|
||||||
|
onPrimary = remoteColor(WidgetColorRole.ON_PRIMARY),
|
||||||
|
cell = remoteColor(monthCellRole()),
|
||||||
|
)
|
||||||
|
|
||||||
MonthHeader(label = monthLabel(ym, 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, colW = colW)
|
||||||
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,89 +273,79 @@ 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(
|
// Tap the month/year title to open the app on the month view (issue #18),
|
||||||
modifier = GlanceModifier.fillMaxWidth(),
|
// rooted over the default home so backing out returns there.
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
WidgetHeader(
|
||||||
|
title = label,
|
||||||
|
titleAction = actionStartActivity(MainActivity.openViewIntent(context, CalendarView.Month)),
|
||||||
|
modifier = GlanceModifier.padding(bottom = 2.dp),
|
||||||
|
titleInset = 4.dp,
|
||||||
) {
|
) {
|
||||||
HeaderIcon(
|
WidgetHeaderAction(
|
||||||
resId = R.drawable.ic_widget_chevron_left,
|
icon = ImageProvider(R.drawable.ic_widget_chevron_left),
|
||||||
contentDescription = context.getString(R.string.widget_prev_month),
|
contentDescription = context.getString(R.string.widget_prev_month),
|
||||||
onClick = GlanceModifier.clickable(
|
action = actionRunCallback<ShiftMonthAction>(actionParametersOf(ShiftMonthAction.deltaKey to -1)),
|
||||||
actionRunCallback<ShiftMonthAction>(
|
style = WidgetActionStyle.TONAL,
|
||||||
actionParametersOf(ShiftMonthAction.deltaKey to -1),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
// Tap the month/year title to open the app on the month view (issue #18),
|
WidgetHeaderActionGap()
|
||||||
// rooted over the default home so backing out returns there.
|
// The "today" pill carries today's number like the app icon, and snaps
|
||||||
Text(
|
// the grid back to the current month in place.
|
||||||
text = label,
|
WidgetHeaderTextAction(
|
||||||
style = TextStyle(
|
text = today.day.toString(),
|
||||||
color = GlanceTheme.colors.primary,
|
|
||||||
fontSize = 15.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
),
|
|
||||||
modifier = GlanceModifier
|
|
||||||
.defaultWeight()
|
|
||||||
.clickable(
|
|
||||||
actionStartActivity(
|
|
||||||
MainActivity.openViewIntent(context, CalendarView.Month),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
// The "today" button snaps the grid back to the current month in place.
|
|
||||||
HeaderIcon(
|
|
||||||
resId = R.drawable.ic_widget_today,
|
|
||||||
contentDescription = context.getString(R.string.widget_today),
|
contentDescription = context.getString(R.string.widget_today),
|
||||||
onClick = GlanceModifier.clickable(actionRunCallback<ResetMonthAction>()),
|
action = actionRunCallback<ResetMonthAction>(),
|
||||||
|
style = WidgetActionStyle.TONAL,
|
||||||
)
|
)
|
||||||
HeaderIcon(
|
WidgetHeaderActionGap()
|
||||||
resId = R.drawable.ic_widget_chevron_right,
|
WidgetHeaderAction(
|
||||||
|
icon = ImageProvider(R.drawable.ic_widget_chevron_right),
|
||||||
contentDescription = context.getString(R.string.widget_next_month),
|
contentDescription = context.getString(R.string.widget_next_month),
|
||||||
onClick = GlanceModifier.clickable(
|
action = actionRunCallback<ShiftMonthAction>(actionParametersOf(ShiftMonthAction.deltaKey to 1)),
|
||||||
actionRunCallback<ShiftMonthAction>(
|
style = WidgetActionStyle.TONAL,
|
||||||
actionParametersOf(ShiftMonthAction.deltaKey to 1),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
WidgetHeaderActionGap()
|
||||||
}
|
WidgetHeaderAction(
|
||||||
|
icon = ImageProvider(R.drawable.ic_widget_add),
|
||||||
@Composable
|
contentDescription = context.getString(R.string.widget_new_event),
|
||||||
private fun HeaderIcon(resId: Int, contentDescription: String, onClick: GlanceModifier) {
|
action = actionStartActivity(MainActivity.openCreateIntent(context, today)),
|
||||||
Box(
|
|
||||||
modifier = GlanceModifier.size(40.dp).then(onClick),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
provider = ImageProvider(resId),
|
|
||||||
contentDescription = contentDescription,
|
|
||||||
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
|
|
||||||
modifier = GlanceModifier.size(20.dp),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WeekdayHeader(weekStart: DayOfWeek, colW: Dp) {
|
private fun WeekdayHeader(weekStart: DayOfWeek, colW: Dp) {
|
||||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
Row(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 1.dp)) {
|
||||||
weekdayNarrowNames(weekStart).forEach { name ->
|
weekdayNarrowNames(weekStart).forEach { name ->
|
||||||
Text(
|
Box(modifier = GlanceModifier.width(colW).padding(horizontal = 1.dp)) {
|
||||||
text = name,
|
Text(
|
||||||
style = TextStyle(
|
text = name,
|
||||||
color = GlanceTheme.colors.onSurfaceVariant,
|
style = TextStyle(
|
||||||
fontSize = 11.sp,
|
color = GlanceTheme.colors.onPrimaryContainer,
|
||||||
textAlign = TextAlign.Center,
|
fontSize = 11.sp,
|
||||||
),
|
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. */
|
||||||
private fun weekdayNarrowNames(weekStart: DayOfWeek): List<String> {
|
private fun weekdayNarrowNames(weekStart: DayOfWeek): List<String> {
|
||||||
@@ -331,266 +356,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 +370,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,10 +1,13 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.month
|
package de.jeanlucmakiola.calendula.widget.month
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.appwidget.AppWidgetManager
|
import android.appwidget.AppWidgetManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.glance.appwidget.GlanceAppWidget
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
|
import de.jeanlucmakiola.calendula.widget.syncWidgetRollover
|
||||||
|
import de.jeanlucmakiola.floret.glance.reloadOnUpdate
|
||||||
|
import de.jeanlucmakiola.floret.glance.restoreState
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Host-facing receiver for the month widget. Declared in the manifest with the
|
* Host-facing receiver for the month widget. Declared in the manifest with the
|
||||||
@@ -16,16 +19,21 @@ class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
|
|||||||
/** First month widget placed — start rolling "today" over at midnight (#228). */
|
/** First month widget placed — start rolling "today" over at midnight (#228). */
|
||||||
override fun onEnabled(context: Context) {
|
override fun onEnabled(context: Context) {
|
||||||
super.onEnabled(context)
|
super.onEnabled(context)
|
||||||
WidgetRolloverScheduler.sync(context)
|
syncWidgetRollover(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Last month widget removed. [WidgetRolloverScheduler.sync] cancels only if
|
* Last month widget removed. [syncWidgetRollover] cancels only if
|
||||||
* no agenda widget is left either.
|
* no agenda widget is left either.
|
||||||
*/
|
*/
|
||||||
override fun onDisabled(context: Context) {
|
override fun onDisabled(context: Context) {
|
||||||
super.onDisabled(context)
|
super.onDisabled(context)
|
||||||
WidgetRolloverScheduler.sync(context)
|
syncWidgetRollover(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A backup restore or device transfer renumbered the widgets: keep each one's settings (#292). */
|
||||||
|
override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) {
|
||||||
|
restoreState(context, oldWidgetIds, newWidgetIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,12 +43,14 @@ class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
|
|||||||
* freeze) is repaired here rather than on the next app open. Re-arming
|
* freeze) is repaired here rather than on the next app open. Re-arming
|
||||||
* closer to midnight also narrows the inexact delivery window.
|
* closer to midnight also narrows the inexact delivery window.
|
||||||
*/
|
*/
|
||||||
|
@SuppressLint("MissingSuperCall")
|
||||||
override fun onUpdate(
|
override fun onUpdate(
|
||||||
context: Context,
|
context: Context,
|
||||||
appWidgetManager: AppWidgetManager,
|
appWidgetManager: AppWidgetManager,
|
||||||
appWidgetIds: IntArray,
|
appWidgetIds: IntArray,
|
||||||
) {
|
) {
|
||||||
super.onUpdate(context, appWidgetManager, appWidgetIds)
|
// In place of super, which only recomposes and would take the goAsync handle.
|
||||||
WidgetRolloverScheduler.sync(context)
|
reloadOnUpdate(context, appWidgetIds)
|
||||||
|
syncWidgetRollover(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp" android:height="24dp"
|
|
||||||
android:viewportWidth="24" android:viewportHeight="24">
|
|
||||||
<path android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
|
|
||||||
</vector>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp" android:height="24dp"
|
|
||||||
android:viewportWidth="24" android:viewportHeight="24">
|
|
||||||
<path android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M19,3h-1V1h-2v2H8V1H6v2H5C3.89,3 3.01,3.9 3.01,5L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2zM19,19H5V8h14v11zM7,10h5v5H7z"/>
|
|
||||||
</vector>
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||||
|
<corners android:radius="4dp" />
|
||||||
|
<solid android:color="@android:color/white" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||||
|
<corners android:radius="8dp" />
|
||||||
|
<solid android:color="@android:color/white" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
|
||||||
|
<solid android:color="@android:color/white" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:paddingHorizontal="1dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/bar_bg"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_bar_bg" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/bar_title"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:paddingHorizontal="3dp"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
</FrameLayout>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="2"
|
||||||
|
android:paddingHorizontal="1dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/bar_bg"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_bar_bg" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/bar_title"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:paddingHorizontal="3dp"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
</FrameLayout>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="3"
|
||||||
|
android:paddingHorizontal="1dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/bar_bg"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_bar_bg" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/bar_title"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:paddingHorizontal="3dp"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
</FrameLayout>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="4"
|
||||||
|
android:paddingHorizontal="1dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/bar_bg"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_bar_bg" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/bar_title"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:paddingHorizontal="3dp"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
</FrameLayout>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="5"
|
||||||
|
android:paddingHorizontal="1dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/bar_bg"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_bar_bg" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/bar_title"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:paddingHorizontal="3dp"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
</FrameLayout>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="6"
|
||||||
|
android:paddingHorizontal="1dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/bar_bg"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_bar_bg" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/bar_title"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:paddingHorizontal="3dp"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
</FrameLayout>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="7"
|
||||||
|
android:paddingHorizontal="1dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/bar_bg"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_bar_bg" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/bar_title"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:paddingHorizontal="3dp"
|
||||||
|
android:textSize="9sp" />
|
||||||
|
</FrameLayout>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="2" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="3" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="4" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="5" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="6" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="7" />
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/month_grid"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical" />
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/lane"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="15dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingBottom="1dp"
|
||||||
|
android:weightSum="7" />
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/more"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:paddingStart="3dp"
|
||||||
|
android:textSize="9sp" />
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/strip_0"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:foreground="?android:attr/selectableItemBackground"
|
||||||
|
android:padding="1dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/cell_0"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_cell_bg" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/cell_dim_0"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_cell_bg"
|
||||||
|
android:visibility="gone" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/strip_1"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:foreground="?android:attr/selectableItemBackground"
|
||||||
|
android:padding="1dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/cell_1"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_cell_bg" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/cell_dim_1"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_cell_bg"
|
||||||
|
android:visibility="gone" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/strip_2"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:foreground="?android:attr/selectableItemBackground"
|
||||||
|
android:padding="1dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/cell_2"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_cell_bg" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/cell_dim_2"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_cell_bg"
|
||||||
|
android:visibility="gone" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/strip_3"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:foreground="?android:attr/selectableItemBackground"
|
||||||
|
android:padding="1dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/cell_3"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_cell_bg" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/cell_dim_3"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_cell_bg"
|
||||||
|
android:visibility="gone" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/strip_4"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:foreground="?android:attr/selectableItemBackground"
|
||||||
|
android:padding="1dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/cell_4"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_cell_bg" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/cell_dim_4"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_cell_bg"
|
||||||
|
android:visibility="gone" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/strip_5"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:foreground="?android:attr/selectableItemBackground"
|
||||||
|
android:padding="1dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/cell_5"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_cell_bg" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/cell_dim_5"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_cell_bg"
|
||||||
|
android:visibility="gone" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/strip_6"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:foreground="?android:attr/selectableItemBackground"
|
||||||
|
android:padding="1dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/cell_6"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_cell_bg" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/cell_dim_6"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/widget_month_cell_bg"
|
||||||
|
android:visibility="gone" />
|
||||||
|
</FrameLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/today_bg_0"
|
||||||
|
android:layout_width="18dp"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/widget_month_today_bg"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/day_0"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:gravity="center"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/today_bg_1"
|
||||||
|
android:layout_width="18dp"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/widget_month_today_bg"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/day_1"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:gravity="center"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/today_bg_2"
|
||||||
|
android:layout_width="18dp"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/widget_month_today_bg"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/day_2"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:gravity="center"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/today_bg_3"
|
||||||
|
android:layout_width="18dp"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/widget_month_today_bg"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/day_3"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:gravity="center"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/today_bg_4"
|
||||||
|
android:layout_width="18dp"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/widget_month_today_bg"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/day_4"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:gravity="center"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/today_bg_5"
|
||||||
|
android:layout_width="18dp"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/widget_month_today_bg"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/day_5"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:gravity="center"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/today_bg_6"
|
||||||
|
android:layout_width="18dp"
|
||||||
|
android:layout_height="18dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/widget_month_today_bg"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/day_6"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:gravity="center"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
</FrameLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/week_lanes"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingTop="2dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
@@ -441,6 +441,21 @@
|
|||||||
<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>
|
||||||
|
|
||||||
<!-- Calendar filter (M3) -->
|
<!-- Calendar filter (M3) -->
|
||||||
<string name="filter_title">Calendars</string>
|
<string name="filter_title">Calendars</string>
|
||||||
@@ -554,7 +569,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 +660,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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -165,15 +165,12 @@ class SettingsPrefsTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `agenda ranges default to month and round-trip independently`(@TempDir tempDir: Path) = runTest {
|
fun `agenda screen range defaults to month and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||||
val prefs = testSettings(tempDir)
|
val prefs = testSettings(tempDir)
|
||||||
assertThat(prefs.agendaScreenRange.first()).isEqualTo(AgendaRange.Month)
|
assertThat(prefs.agendaScreenRange.first()).isEqualTo(AgendaRange.Month)
|
||||||
assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Month)
|
|
||||||
|
|
||||||
prefs.setAgendaScreenRange(AgendaRange.Custom(14))
|
prefs.setAgendaScreenRange(AgendaRange.Custom(14))
|
||||||
prefs.setAgendaWidgetRange(AgendaRange.Week)
|
|
||||||
assertThat(prefs.agendaScreenRange.first()).isEqualTo(AgendaRange.Custom(14))
|
assertThat(prefs.agendaScreenRange.first()).isEqualTo(AgendaRange.Custom(14))
|
||||||
assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Week)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class MonthWindowCenterTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the loaded window always reaches two months past the shown one`() {
|
||||||
|
for (index in -40..40) {
|
||||||
|
val center = monthWindowCenter(index)
|
||||||
|
assertThat(index - 2).isAtLeast(center - MONTH_WIDGET_PAD_MONTHS)
|
||||||
|
assertThat(index + 2).isAtMost(center + MONTH_WIDGET_PAD_MONTHS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `paging within a step of three keeps the same window`() {
|
||||||
|
val year = 2026 * 12
|
||||||
|
assertThat(monthWindowCenter(year)).isEqualTo(monthWindowCenter(year + 2))
|
||||||
|
assertThat(monthWindowCenter(year + 3)).isNotEqualTo(monthWindowCenter(year + 2))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget
|
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
|
||||||
import kotlinx.datetime.LocalDate
|
|
||||||
import kotlinx.datetime.LocalDateTime
|
|
||||||
import kotlinx.datetime.TimeZone
|
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
|
||||||
import kotlinx.datetime.toInstant
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import kotlin.time.Duration
|
|
||||||
import kotlin.time.Duration.Companion.hours
|
|
||||||
import kotlin.time.Duration.Companion.minutes
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The rollover alarm is what makes the widgets stop highlighting yesterday
|
|
||||||
* (#228), so the "when is the next local midnight" arithmetic is the one piece
|
|
||||||
* worth pinning down — especially where midnight is not 00:00.
|
|
||||||
*/
|
|
||||||
class WidgetRolloverSchedulerTest {
|
|
||||||
|
|
||||||
private val berlin = TimeZone.of("Europe/Berlin")
|
|
||||||
|
|
||||||
private fun at(local: String, zone: TimeZone): Instant =
|
|
||||||
LocalDateTime.parse(local).toInstant(zone)
|
|
||||||
|
|
||||||
private fun nextRollover(local: String, zone: TimeZone = berlin): Instant =
|
|
||||||
WidgetRolloverScheduler.nextRolloverAt(at(local, zone), zone)
|
|
||||||
|
|
||||||
// --- the ordinary day ----------------------------------------------------
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `midday rolls over at the coming midnight`() {
|
|
||||||
val next = nextRollover("2026-08-27T12:00:00")
|
|
||||||
assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a second before midnight still targets tonight, not tomorrow night`() {
|
|
||||||
val next = nextRollover("2026-08-27T23:59:59")
|
|
||||||
assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `at midnight exactly the target is the next day, never the current instant`() {
|
|
||||||
// Re-arming after a firing must move a whole day on, or the widget wakes
|
|
||||||
// itself in a tight loop.
|
|
||||||
val now = at("2026-08-28T00:00:00", berlin)
|
|
||||||
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
|
|
||||||
assertThat(next).isEqualTo(at("2026-08-29T00:00:05", berlin))
|
|
||||||
assertThat(next - now).isGreaterThan(Duration.ZERO)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `re-arming from the slack instant itself moves a full day on`() {
|
|
||||||
// What actually happens in practice: the receiver runs at midnight + slack.
|
|
||||||
val now = at("2026-08-28T00:00:05", berlin)
|
|
||||||
assertThat(WidgetRolloverScheduler.nextRolloverAt(now, berlin))
|
|
||||||
.isEqualTo(at("2026-08-29T00:00:05", berlin))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `the result is always in the future for every minute of a day`() {
|
|
||||||
val zone = berlin
|
|
||||||
// Spans Berlin's 2024 spring-forward: the likeliest source of a target
|
|
||||||
// in the past, i.e. an alarm that fires immediately, forever.
|
|
||||||
var probe = LocalDateTime.parse("2024-03-29T00:00:00").toInstant(zone)
|
|
||||||
val end = LocalDateTime.parse("2024-04-01T00:00:00").toInstant(zone)
|
|
||||||
while (probe < end) {
|
|
||||||
assertThat(WidgetRolloverScheduler.nextRolloverAt(probe, zone)).isGreaterThan(probe)
|
|
||||||
probe += 1.minutes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- daylight saving -----------------------------------------------------
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `spring forward keeps the rollover one day away, not one hour short`() {
|
|
||||||
// Berlin skipped 02:00-03:00 on 31 March 2024, so that day was 23h long.
|
|
||||||
// A rollover computed as "now + 24h" would land at 01:00 on 1 April.
|
|
||||||
val now = at("2024-03-30T12:00:00", berlin)
|
|
||||||
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
|
|
||||||
assertThat(next).isEqualTo(at("2024-03-31T00:00:05", berlin))
|
|
||||||
assertThat(next - now).isLessThan(24.hours)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `fall back does not overshoot into the repeated hour`() {
|
|
||||||
// Berlin repeated 02:00-03:00 on 27 October 2024: a 25h day, so
|
|
||||||
// "now + 24h" would land at 23:00 on the 26th and never roll over.
|
|
||||||
val now = at("2024-10-26T12:00:00", berlin)
|
|
||||||
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
|
|
||||||
assertThat(next).isEqualTo(at("2024-10-27T00:00:05", berlin))
|
|
||||||
assertThat(next.toLocalDateTime(berlin).date).isEqualTo(LocalDate.parse("2024-10-27"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a zone that repeats midnight takes the first start of day`() {
|
|
||||||
// Sao Paulo used to end DST by moving 00:00 back to 23:00, so the day
|
|
||||||
// began twice. Pinned as the accepted trade: the widget runs an hour
|
|
||||||
// ahead until the next redraw. No live zone does this since 2019.
|
|
||||||
val saoPaulo = TimeZone.of("America/Sao_Paulo")
|
|
||||||
val next = WidgetRolloverScheduler.nextRolloverAt(
|
|
||||||
at("2018-02-16T12:00:00", saoPaulo), saoPaulo,
|
|
||||||
)
|
|
||||||
val local = next.toLocalDateTime(saoPaulo)
|
|
||||||
assertThat(local.date).isEqualTo(LocalDate.parse("2018-02-17"))
|
|
||||||
assertThat(local.hour).isEqualTo(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a zone where midnight does not exist rolls over at the real start of day`() {
|
|
||||||
// Cuba starts DST at 00:00, so 11 March 2018 began at 01:00 in Havana.
|
|
||||||
// Targeting a literal 00:00 there would arm an instant on the wrong day.
|
|
||||||
val havana = TimeZone.of("America/Havana")
|
|
||||||
val next = WidgetRolloverScheduler.nextRolloverAt(at("2018-03-10T12:00:00", havana), havana)
|
|
||||||
val local = next.toLocalDateTime(havana)
|
|
||||||
assertThat(local.date).isEqualTo(LocalDate.parse("2018-03-11"))
|
|
||||||
assertThat(local.hour).isEqualTo(1)
|
|
||||||
assertThat(local.minute).isEqualTo(0)
|
|
||||||
// And it is genuinely the first instant of that date, not a guess.
|
|
||||||
assertThat(next).isEqualTo(
|
|
||||||
LocalDate.parse("2018-03-11").atStartOfDayIn(havana) +
|
|
||||||
WidgetRolloverScheduler.ROLLOVER_SLACK,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- timezone changes ----------------------------------------------------
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `the same instant rolls over at different times in different zones`() {
|
|
||||||
// TIMEZONE_CHANGED must re-arm to the new local midnight: the arithmetic
|
|
||||||
// follows the zone, not a cached offset.
|
|
||||||
val instant = at("2026-08-27T12:00:00", berlin)
|
|
||||||
val tokyo = TimeZone.of("Asia/Tokyo")
|
|
||||||
val berlinNext = WidgetRolloverScheduler.nextRolloverAt(instant, berlin)
|
|
||||||
val tokyoNext = WidgetRolloverScheduler.nextRolloverAt(instant, tokyo)
|
|
||||||
assertThat(tokyoNext).isNotEqualTo(berlinNext)
|
|
||||||
assertThat(tokyoNext).isLessThan(berlinNext)
|
|
||||||
assertThat(tokyoNext.toLocalDateTime(tokyo).hour).isEqualTo(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a half-hour offset zone still lands on its own midnight`() {
|
|
||||||
val kathmandu = TimeZone.of("Asia/Kathmandu")
|
|
||||||
val next = WidgetRolloverScheduler.nextRolloverAt(
|
|
||||||
at("2026-08-27T12:00:00", kathmandu), kathmandu,
|
|
||||||
)
|
|
||||||
val local = next.toLocalDateTime(kathmandu)
|
|
||||||
assertThat(local.date.toString()).isEqualTo("2026-08-28")
|
|
||||||
assertThat(local.hour).isEqualTo(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- the wiring ----------------------------------------------------------
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `the receiver actually handles the action the alarm is sent with`() {
|
|
||||||
// The single point where the whole fix would die silently: the alarm
|
|
||||||
// fires, the receiver drops it on the action guard, nothing redraws.
|
|
||||||
assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS)
|
|
||||||
.contains(WidgetUpdateReceiver.ACTION_ROLLOVER)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a data change is handled, and re-arms only on the throttle`() {
|
|
||||||
// PROVIDER_CHANGED fires on every calendar write — each event of a bulk
|
|
||||||
// import, each drop of a drag — so it is not an unconditional re-arm.
|
|
||||||
// It is not dropped either: the alarm is inexact and the system may lose
|
|
||||||
// it, and this is the only signal that keeps arriving to notice.
|
|
||||||
assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS)
|
|
||||||
.containsAtLeast(Intent.ACTION_PROVIDER_CHANGED, Intent.ACTION_DATE_CHANGED)
|
|
||||||
assertThat(WidgetUpdateReceiver.REARM_ACTIONS)
|
|
||||||
.doesNotContain(Intent.ACTION_PROVIDER_CHANGED)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `everything that loses the alarm re-arms it`() {
|
|
||||||
assertThat(WidgetUpdateReceiver.REARM_ACTIONS).containsAtLeast(
|
|
||||||
WidgetUpdateReceiver.ACTION_ROLLOVER,
|
|
||||||
Intent.ACTION_BOOT_COMPLETED,
|
|
||||||
Intent.ACTION_MY_PACKAGE_REPLACED,
|
|
||||||
Intent.ACTION_TIME_CHANGED,
|
|
||||||
Intent.ACTION_TIMEZONE_CHANGED,
|
|
||||||
Intent.ACTION_DATE_CHANGED,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,25 +15,12 @@ class AgendaScaleTest {
|
|||||||
// SMALL is the default, so if this fails an agenda widget whose owner never
|
// SMALL is the default, so if this fails an agenda widget whose owner never
|
||||||
// touched the size setting no longer looks as it did (#51).
|
// touched the size setting no longer looks as it did (#51).
|
||||||
val m = metricsFor(WidgetSize.SMALL)
|
val m = metricsFor(WidgetSize.SMALL)
|
||||||
assertThat(m.title).isEqualTo(16.sp)
|
|
||||||
assertThat(m.dayHeader).isEqualTo(13.sp)
|
|
||||||
assertThat(m.eventTitle).isEqualTo(14.sp)
|
assertThat(m.eventTitle).isEqualTo(14.sp)
|
||||||
assertThat(m.eventTime).isEqualTo(12.sp)
|
assertThat(m.eventTime).isEqualTo(12.sp)
|
||||||
assertThat(m.placeholder).isEqualTo(14.sp)
|
assertThat(m.placeholder).isEqualTo(14.sp)
|
||||||
assertThat(m.message).isEqualTo(14.sp)
|
assertThat(m.message).isEqualTo(14.sp)
|
||||||
assertThat(m.stripeH).isEqualTo(36.dp)
|
assertThat(m.contentH).isEqualTo(36.dp)
|
||||||
assertThat(m.iconImage).isEqualTo(22.dp)
|
|
||||||
assertThat(m.iconBox).isEqualTo(40.dp)
|
|
||||||
assertThat(m.rowVPad).isEqualTo(4.dp)
|
assertThat(m.rowVPad).isEqualTo(4.dp)
|
||||||
assertThat(m.dayHeaderTopPad).isEqualTo(10.dp)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `the derived text indent matches the original hardcoded inset`() {
|
|
||||||
// TEXT_INDENT replaced a hardcoded 19dp; it must still resolve to 19dp or
|
|
||||||
// day headers and the placeholder line stop aligning with event titles.
|
|
||||||
assertThat(TEXT_INDENT).isEqualTo(19.dp)
|
|
||||||
assertThat(TEXT_INDENT).isEqualTo(ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- the ramp ------------------------------------------------------------
|
// --- the ramp ------------------------------------------------------------
|
||||||
@@ -43,17 +30,17 @@ class AgendaScaleTest {
|
|||||||
val steps = WidgetSize.entries.map(::metricsFor)
|
val steps = WidgetSize.entries.map(::metricsFor)
|
||||||
|
|
||||||
steps.zipWithNext { small, big ->
|
steps.zipWithNext { small, big ->
|
||||||
assertThat(big.title.value).isAtLeast(small.title.value)
|
assertThat(big.pillWeekday.value).isAtLeast(small.pillWeekday.value)
|
||||||
assertThat(big.dayHeader.value).isAtLeast(small.dayHeader.value)
|
assertThat(big.pillDay.value).isAtLeast(small.pillDay.value)
|
||||||
assertThat(big.eventTitle.value).isAtLeast(small.eventTitle.value)
|
assertThat(big.eventTitle.value).isAtLeast(small.eventTitle.value)
|
||||||
assertThat(big.eventTime.value).isAtLeast(small.eventTime.value)
|
assertThat(big.eventTime.value).isAtLeast(small.eventTime.value)
|
||||||
assertThat(big.placeholder.value).isAtLeast(small.placeholder.value)
|
assertThat(big.placeholder.value).isAtLeast(small.placeholder.value)
|
||||||
assertThat(big.message.value).isAtLeast(small.message.value)
|
assertThat(big.message.value).isAtLeast(small.message.value)
|
||||||
assertThat(big.stripeH.value).isAtLeast(small.stripeH.value)
|
assertThat(big.contentH.value).isAtLeast(small.contentH.value)
|
||||||
assertThat(big.iconImage.value).isAtLeast(small.iconImage.value)
|
assertThat(big.pillWidth.value).isAtLeast(small.pillWidth.value)
|
||||||
assertThat(big.iconBox.value).isAtLeast(small.iconBox.value)
|
|
||||||
assertThat(big.rowVPad.value).isAtLeast(small.rowVPad.value)
|
assertThat(big.rowVPad.value).isAtLeast(small.rowVPad.value)
|
||||||
assertThat(big.dayHeaderTopPad.value).isAtLeast(small.dayHeaderTopPad.value)
|
assertThat(big.dayGap.value).isAtLeast(small.dayGap.value)
|
||||||
|
assertThat(big.monthLabel.value).isAtLeast(small.monthLabel.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,20 +58,28 @@ class AgendaScaleTest {
|
|||||||
// Guards against a future edit turning "more readable" into "absurd".
|
// Guards against a future edit turning "more readable" into "absurd".
|
||||||
val base = metricsFor(WidgetSize.SMALL)
|
val base = metricsFor(WidgetSize.SMALL)
|
||||||
val top = metricsFor(WidgetSize.EXTRA_LARGE)
|
val top = metricsFor(WidgetSize.EXTRA_LARGE)
|
||||||
assertThat(top.title.value / base.title.value).isLessThan(1.5f)
|
assertThat(top.pillDay.value / base.pillDay.value).isLessThan(1.5f)
|
||||||
assertThat(top.eventTitle.value / base.eventTitle.value).isLessThan(1.5f)
|
assertThat(top.eventTitle.value / base.eventTitle.value).isLessThan(1.5f)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- font scale ----------------------------------------------------------
|
// --- font scale ----------------------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the stripe tracks the system font scale`() {
|
fun `the row height tracks the system font scale`() {
|
||||||
// The stripe is Dp, the text beside it is sp: without this the two diverge
|
// The row height is Dp, the text in it is sp: without this the two diverge
|
||||||
// at large accessibility font settings and the stripe under-runs the row.
|
// at large accessibility font settings and the text outgrows the row.
|
||||||
val m = metricsFor(WidgetSize.SMALL)
|
val m = metricsFor(WidgetSize.SMALL)
|
||||||
assertThat(m.scaledForFont(1f).stripeH).isEqualTo(36.dp)
|
assertThat(m.scaledForFont(1f).contentH).isEqualTo(36.dp)
|
||||||
assertThat(m.scaledForFont(1.3f).stripeH.value).isWithin(0.01f).of(46.8f)
|
assertThat(m.scaledForFont(1.3f).contentH.value).isWithin(0.01f).of(46.8f)
|
||||||
assertThat(m.scaledForFont(0.85f).stripeH.value).isWithin(0.01f).of(30.6f)
|
assertThat(m.scaledForFont(0.85f).contentH.value).isWithin(0.01f).of(30.6f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the date pill widens with the system font scale`() {
|
||||||
|
// The pill's text is sp; a fixed-width pill clips "Wed" / "23" at large font settings.
|
||||||
|
val m = metricsFor(WidgetSize.SMALL)
|
||||||
|
assertThat(m.scaledForFont(2f).pillWidth).isEqualTo(m.pillWidth * 2)
|
||||||
|
assertThat(m.scaledForFont(2f).rowHeight).isEqualTo(m.contentH * 2 + m.rowVPad * 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -99,7 +94,7 @@ class AgendaScaleTest {
|
|||||||
// double-count it.
|
// double-count it.
|
||||||
val m = metricsFor(WidgetSize.MEDIUM)
|
val m = metricsFor(WidgetSize.MEDIUM)
|
||||||
val scaled = m.scaledForFont(1.3f)
|
val scaled = m.scaledForFont(1.3f)
|
||||||
assertThat(scaled.title).isEqualTo(m.title)
|
assertThat(scaled.pillDay).isEqualTo(m.pillDay)
|
||||||
assertThat(scaled.eventTitle).isEqualTo(m.eventTitle)
|
assertThat(scaled.eventTitle).isEqualTo(m.eventTitle)
|
||||||
assertThat(scaled.rowVPad).isEqualTo(m.rowVPad)
|
assertThat(scaled.rowVPad).isEqualTo(m.rowVPad)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.month
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.allDayEvent
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.timedEvent
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.Month
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.YearMonth
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/** How the month widget seats a week's events into lanes (#372). */
|
||||||
|
class MonthGridTest {
|
||||||
|
|
||||||
|
private val zone = TimeZone.UTC
|
||||||
|
private val jul26 = YearMonth(2026, Month.JULY)
|
||||||
|
|
||||||
|
/** Monday Jul 6 to Sunday Jul 12. */
|
||||||
|
private val monday = LocalDate(2026, 7, 6)
|
||||||
|
|
||||||
|
private fun weeks(events: List<EventInstance>): List<MonthWeek> =
|
||||||
|
layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone)
|
||||||
|
|
||||||
|
/** July 2026 with [perDay] timed events on every day of the grid. */
|
||||||
|
private fun busyWeeks(perDay: Int): List<MonthWeek> {
|
||||||
|
val first = LocalDate(2026, 6, 29)
|
||||||
|
var id = 0L
|
||||||
|
return weeks(
|
||||||
|
(0 until 42).flatMap { offset ->
|
||||||
|
val day = first.plus(offset, DateTimeUnit.DAY)
|
||||||
|
(1..perDay).map { hour -> timedEvent(day, hour, zone = zone, id = ++id, title = "E$id") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a week that fits keeps every lane`() {
|
||||||
|
val layouts = fitWeeks(busyWeeks(perDay = 2), lanes = 3)
|
||||||
|
assertThat(layouts.map { it.laneCap }.distinct()).containsExactly(3)
|
||||||
|
assertThat(layouts.all { it.overflow.isEmpty() }).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a week that doesn't fit gives its last lane to the overflow`() {
|
||||||
|
val layouts = fitWeeks(busyWeeks(perDay = 5), lanes = 3)
|
||||||
|
assertThat(layouts.map { it.laneCap }.distinct()).containsExactly(2)
|
||||||
|
assertThat(layouts.all { it.overflow.isNotEmpty() }).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a taller week fits more lanes`() {
|
||||||
|
assertThat(lanesFitting(19.dp)).isEqualTo(0)
|
||||||
|
assertThat(lanesFitting(50.dp)).isEqualTo(2)
|
||||||
|
assertThat(lanesFitting(100.dp)).isEqualTo(5)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `lanes stop at the cap however tall the week`() {
|
||||||
|
assertThat(lanesFitting(1000.dp)).isEqualTo(MAX_LANES)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a week with no room for a lane draws no overflow row either`() {
|
||||||
|
val layouts = fitWeeks(busyWeeks(perDay = 5), lanes = 0)
|
||||||
|
assertThat(layouts.none { it.showsOverflow }).isTrue()
|
||||||
|
assertThat(layouts.all { it.lanes.isEmpty() }).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `folded events are counted in the overflow`() {
|
||||||
|
val layout = WeekLayout(busyWeeks(perDay = 5)[1], laneCap = 1)
|
||||||
|
assertThat(layout.usedLanes).isEqualTo(1)
|
||||||
|
assertThat(layout.overflow).containsExactly(GridCell(4, 1), GridCell(4, 1), GridCell(4, 1),
|
||||||
|
GridCell(4, 1), GridCell(4, 1), GridCell(4, 1), GridCell(4, 1)).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a multi-day bar is one cell as wide as its days`() {
|
||||||
|
val bar = allDayEvent(monday.plus(1, DateTimeUnit.DAY), monday.plus(3, DateTimeUnit.DAY), id = 1)
|
||||||
|
val layout = WeekLayout(weeks(listOf(bar))[1], laneCap = 3)
|
||||||
|
assertThat(layout.lanes).hasSize(1)
|
||||||
|
assertThat(layout.lanes[0]).containsExactly(GridCell(null, 1), GridCell(bar, 3), GridCell(null, 3)).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bar on a folded lane moves into the overflow of each day it covers`() {
|
||||||
|
val top = allDayEvent(monday, monday.plus(6, DateTimeUnit.DAY), id = 1)
|
||||||
|
val lower = allDayEvent(monday.plus(2, DateTimeUnit.DAY), monday.plus(3, DateTimeUnit.DAY), id = 2)
|
||||||
|
val layout = WeekLayout(weeks(listOf(top, lower))[1], laneCap = 1)
|
||||||
|
assertThat(layout.lanes).hasSize(1)
|
||||||
|
assertThat(layout.lanes[0]).containsExactly(GridCell(top, 7))
|
||||||
|
assertThat(layout.overflow).containsExactly(GridCell(null, 2), GridCell(1, 1), GridCell(1, 1), GridCell(null, 3))
|
||||||
|
.inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `gaps are merged`() {
|
||||||
|
val counts = listOf(0, 0, 2, 0, 0, 0, 1)
|
||||||
|
val cells = mergeGaps(counts.size) { col -> counts[col].takeIf { it > 0 }?.let { GridCell(it, 1) } }
|
||||||
|
assertThat(cells).containsExactly(GridCell(null, 2), GridCell(2, 1), GridCell(null, 3), GridCell(1, 1))
|
||||||
|
.inOrder()
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
Submodule floret-kit updated: 9f90027dcd...f2ffaed925
Reference in New Issue
Block a user