diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 16ea6ab..27c551c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -181,6 +181,7 @@ dependencies { implementation("de.jeanlucmakiola.floret:core-android") implementation("de.jeanlucmakiola.floret:identity") implementation("de.jeanlucmakiola.floret:components") + implementation("de.jeanlucmakiola.floret:glance") implementation(libs.kotlinx.coroutines.core) debugImplementation(libs.androidx.ui.tooling) diff --git a/app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetPayloadTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetPayloadTest.kt new file mode 100644 index 0000000..5b02619 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetPayloadTest.kt @@ -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 + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b7cf5c5..71929e5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -318,6 +318,16 @@ android:exported="false" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt index 3f9b88c..d7cc846 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt @@ -10,7 +10,9 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker -import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler +import de.jeanlucmakiola.calendula.widget.publishCalendulaWidgetPreviews +import de.jeanlucmakiola.calendula.widget.syncWidgetRollover +import de.jeanlucmakiola.calendula.widget.widgetEntryPoint import de.jeanlucmakiola.floret.crash.CrashConfig import de.jeanlucmakiola.floret.crash.CrashReporter import kotlinx.coroutines.CoroutineScope @@ -43,6 +45,8 @@ class CalendulaApp : Application() { chooseIssueUrl = getString(R.string.report_issue_choose_url), ), ) + // Before anything that writes, so the widgets see every change (#420). + widgetEntryPoint().widgetRefreshCoordinator().start() reconcileAutoBackup() reconcileSpecialDates() reconcileCalendarVisibility() @@ -55,10 +59,12 @@ class CalendulaApp : Application() { * (#228). Idempotent, and it covers what no broadcast reaches — an alarm * dropped by a force-stop is armed again the next time the app is opened. * Off the main thread: a handful of binder calls on every process start. + * The picker previews are republished here too, once per update. */ private fun reconcileWidgetRollover() { CoroutineScope(SupervisorJob() + Dispatchers.Default).launch { - WidgetRolloverScheduler.sync(this@CalendulaApp) + syncWidgetRollover(this@CalendulaApp) + publishCalendulaWidgetPreviews(this@CalendulaApp) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index 7d2101b..62f7670 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -56,6 +56,10 @@ import javax.inject.Singleton interface CalendarDataSource { fun calendars(): List fun instances(beginMillis: Long, endMillis: Long): List + + /** [instances] of event [eventId] alone, soonest first. */ + fun eventInstances(eventId: Long, beginMillis: Long, endMillis: Long): List = + instances(beginMillis, endMillis).filter { it.eventId == eventId } /** * [allDayReminderTimeMinutes]: the hour all-day reminders fire at, needed to * decode their stored offsets back to whole-day lead times (see @@ -563,21 +567,33 @@ class AndroidCalendarDataSource @Inject constructor( } } - override fun instances(beginMillis: Long, endMillis: Long): List { + override fun instances(beginMillis: Long, endMillis: Long): List = + queryInstances(beginMillis, endMillis, null, null) + + override fun eventInstances(eventId: Long, beginMillis: Long, endMillis: Long): List = + queryInstances(beginMillis, endMillis, "${CalendarContract.Instances.EVENT_ID} = ?", arrayOf(eventId.toString())) + + private fun queryInstances( + beginMillis: Long, + endMillis: Long, + extraSelection: String?, + args: Array?, + ): List { ensureObserversRegistered() + // Hide cancelled occurrences: "delete only this event" writes a + // cancelled exception for the one instance (#47). A NULL status is a + // normal, un-cancelled event, so it must survive the filter — a bare + // `!= CANCELED` would drop it (NULL != 2 is NULL, not true). + // DELETED = 0: a provider that soft-deletes may keep the row's + // instances until a purge (#364). + val visible = "(${CalendarContract.Instances.STATUS} IS NULL OR " + + "${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}) AND " + + "${CalendarContract.Events.DELETED} = 0" return resolver.query( instancesUri(beginMillis, endMillis), InstanceProjection.COLUMNS, - // Hide cancelled occurrences: "delete only this event" writes a - // cancelled exception for the one instance (#47). A NULL status is a - // normal, un-cancelled event, so it must survive the filter — a bare - // `!= CANCELED` would drop it (NULL != 2 is NULL, not true). - // DELETED = 0: a provider that soft-deletes may keep the row's - // instances until a purge (#364). - "(${CalendarContract.Instances.STATUS} IS NULL OR " + - "${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}) AND " + - "${CalendarContract.Events.DELETED} = 0", - null, + if (extraSelection == null) visible else "$visible AND $extraSelection", + args, CalendarContract.Instances.BEGIN + " ASC", )?.use { c -> c.mapRowsNotNull { toEventInstance() } } ?: emptyList() } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt index d140a75..e0fa731 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt @@ -16,8 +16,15 @@ interface CalendarRepository { fun calendars(): Flow> fun instances(range: ClosedRange): Flow> + /** One read of event [eventId]'s occurrences overlapping [range], soonest first, whatever calendar it is in. */ + suspend fun eventOccurrences(eventId: Long, range: ClosedRange): List + /** Re-query every open [calendars] / [instances] flow. */ fun refresh() + + /** Emits on the app's own writes and visibility changes; not on provider notifications or [refresh]. */ + val localChanges: Flow + suspend fun eventDetail(eventId: Long): EventDetail /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt index 27c5166..537fc48 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt @@ -78,15 +78,28 @@ class CalendarRepositoryImpl @Inject constructor( ticks.tryEmit(Unit) } + private val writes = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + /** Run a provider write on [io], then re-query whether or not it threw. */ private suspend fun write(block: suspend () -> T): T = withContext(io) { try { block() } finally { refresh() + writes.tryEmit(Unit) } } + override val localChanges: Flow = 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 * notifications, plus every change to the pending switch-off set. @@ -181,6 +194,11 @@ class CalendarRepositoryImpl @Inject constructor( cachedCalendars } + override suspend fun eventOccurrences(eventId: Long, range: ClosedRange): List = + withContext(io) { + dataSource.eventInstances(eventId, range.start.toEpochMillis(), range.endInclusive.toEpochMillis()) + } + override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) { dataSource.eventDetail(eventId, allDayReminderTimeMinutes()) ?: throw NoSuchEventException(eventId) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 883424b..3f6a630 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -154,8 +154,6 @@ class SettingsPrefs @Inject constructor( val dragToReschedule: Flow = prefs.flow(SettingsKeys.dragToReschedule) val tapOpensEditor: Flow = prefs.flow(SettingsKeys.tapOpensEditor) val agendaScreenRange: Flow = prefs.flow(SettingsKeys.agendaScreenRange) - val agendaWidgetRange: Flow = prefs.flow(SettingsKeys.agendaWidgetRange) - val widgetSize: Flow = prefs.flow(SettingsKeys.widgetSize) val agendaShowRangeBar: Flow = prefs.flow(SettingsKeys.agendaShowRangeBar) val agendaShowToday: Flow = prefs.flow(SettingsKeys.agendaShowToday) val defaultView: Flow = prefs.flow(SettingsKeys.defaultView) @@ -231,8 +229,6 @@ class SettingsPrefs @Inject constructor( suspend fun setDragToReschedule(enabled: Boolean) = prefs.set(SettingsKeys.dragToReschedule, enabled) suspend fun setTapOpensEditor(enabled: Boolean) = prefs.set(SettingsKeys.tapOpensEditor, enabled) 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 setAgendaShowToday(enabled: Boolean) = prefs.set(SettingsKeys.agendaShowToday, enabled) 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). */ val agendaScreenRange: Pref = 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 = agendaRangePref("agenda_widget_range") /** * The size step the agenda widget draws its text at (#51). [WidgetSize.SMALL] - * reproduces its original metrics. The month widget takes no size setting — - * it divides the width it is given by seven (#103). + * reproduces its original metrics. Read-only like [agendaWidgetRange] (#292). */ val widgetSize: Pref = enumPref("widget_size", WidgetSize.SMALL) @@ -511,8 +510,8 @@ object SettingsKeys { val agendaShowRangeBar: Pref = booleanPref("agenda_show_range_bar", true) /** - * Whether the agenda (screen and widget) always anchors today at the top with - * a "nothing left today" placeholder (issue #35). + * Whether the agenda screen always anchors today at the top with a "nothing + * left today" placeholder (issue #35), and the default for a widget (#292). */ val agendaShowToday: Pref = booleanPref("agenda_show_today", true) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt index dc055fc..da8da73 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeZonePicker.kt @@ -101,9 +101,10 @@ fun TimeZonePickerDialog( onDismiss = onDismiss, scrollable = false, ) { - SearchField( + PickerSearchField( query = query, onQueryChange = { query = it }, + placeholder = stringResource(R.string.event_edit_timezone_search), modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) { @@ -181,9 +182,10 @@ fun TimeZonePickerDialog( * which has a top bar to put it in) because the picker's bar is the title. */ @Composable -private fun SearchField( +internal fun PickerSearchField( query: String, onQueryChange: (String) -> Unit, + placeholder: String, modifier: Modifier = Modifier, ) { val keyboard = LocalSoftwareKeyboardController.current @@ -206,7 +208,7 @@ private fun SearchField( InlineTextField( value = query, onValueChange = onQueryChange, - placeholder = stringResource(R.string.event_edit_timezone_search), + placeholder = placeholder, capitalization = KeyboardCapitalization.None, imeAction = ImeAction.Search, onImeAction = { keyboard?.hide() }, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index 756dbe0..2ffe433 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -155,7 +155,7 @@ fun SettingsScreen( enter = slideInHorizontally(slideSpec) { it } + fadeIn(), exit = overlayExit, ) { - WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Widgets) + WidgetsScreen(onBack = { section = null }, visible = section == SettingsSection.Widgets) } AnimatedVisibility( visible = section == SettingsSection.Licences, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index 5d4dcf1..53c14bb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -16,7 +16,6 @@ import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig import de.jeanlucmakiola.calendula.ui.common.TimelineHours import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle -import de.jeanlucmakiola.calendula.widget.WidgetSize /** * Settings screen state (M4). Persisted preferences are instant to read, so @@ -51,14 +50,10 @@ data class SettingsUiState( val tapOpensEditor: Boolean = false, /** How far ahead the in-app Agenda screen shows events (v2.11). */ val agendaScreenRange: AgendaRange = AgendaRange.Month, - /** How far ahead the agenda widget shows events (v2.11). */ - val agendaWidgetRange: AgendaRange = AgendaRange.Month, - /** Whether the agenda (screen + widget) always anchors today at the top (#35). */ + /** Whether the agenda screen always anchors today at the top (#35); widgets have their own (#292). */ val agendaShowToday: Boolean = true, /** Whether the agenda shows its top range bar — header + switcher (v2.11). */ 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). */ val defaultView: CalendarView = CalendarView.Week, /** Which views the top-bar quick-switch button cycles through, and their order (#24). */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index cbbe707..8326032 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -3,9 +3,6 @@ package de.jeanlucmakiola.calendula.ui.settings import android.content.Context import android.net.Uri 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.lifecycle.ViewModel 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.contacts.SpecialDateType 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.TimelineHours import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings 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.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -67,8 +56,6 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import javax.inject.Inject @@ -118,10 +105,8 @@ class SettingsViewModel @Inject constructor( dragToReschedule = this[SettingsKeys.dragToReschedule], tapOpensEditor = this[SettingsKeys.tapOpensEditor], agendaScreenRange = this[SettingsKeys.agendaScreenRange], - agendaWidgetRange = this[SettingsKeys.agendaWidgetRange], agendaShowToday = this[SettingsKeys.agendaShowToday], agendaShowRangeBar = this[SettingsKeys.agendaShowRangeBar], - widgetSize = this[SettingsKeys.widgetSize], defaultView = this[SettingsKeys.defaultView], quickSwitchConfig = this[SettingsKeys.quickSwitchConfig], monthViewStyle = this[SettingsKeys.monthViewStyle], @@ -181,10 +166,6 @@ class SettingsViewModel @Inject constructor( private val _fontImportFailed = MutableSharedFlow(extraBufferCapacity = 1) val fontImportFailed: SharedFlow = _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). */ val specialDatesState: StateFlow = prefs.data.map { stored -> val templates = stored.specialDatesTitleTemplates @@ -267,16 +248,7 @@ class SettingsViewModel @Inject constructor( fun setDynamicColor(enabled: Boolean) = save { setDynamicColor(enabled) } - fun setSoftenColors(enabled: Boolean) { - viewModelScope.launch { - prefs.setSoftenCalendarColors(enabled) - // Both widgets paint event colours through the same softener. - widgetRefreshMutex.withLock { - AgendaWidget().updateAll(appContext) - MonthWidget().updateAll(appContext) - } - } - } + fun setSoftenColors(enabled: Boolean) = save { setSoftenCalendarColors(enabled) } fun setFont(role: FontRole, token: String) = save { when (role) { @@ -304,68 +276,13 @@ class SettingsViewModel @Inject constructor( } } - fun setWeekStart(pref: WeekStartPref) { - 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 setWeekStart(pref: WeekStartPref) = save { setWeekStart(pref) } 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) } - /** - * 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 setAgendaShowToday(enabled: Boolean) = save { setAgendaShowToday(enabled) } fun setTimeFormat(pref: TimeFormatPref) = save { setTimeFormat(pref) } @@ -396,19 +313,7 @@ class SettingsViewModel @Inject constructor( } } - fun setPastEventDisplay(mode: PastEventDisplay) { - 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 setPastEventDisplay(mode: PastEventDisplay) = save { setPastEventDisplay(mode) } fun setDimCompletedEvents(enabled: Boolean) = save { setDimCompletedEvents(enabled) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt index 456efbe..c383197 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WidgetSettings.kt @@ -10,7 +10,9 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable 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.setValue import androidx.compose.ui.Modifier @@ -18,56 +20,46 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import de.jeanlucmakiola.calendula.R -import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay 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.GroupedHint import de.jeanlucmakiola.floret.components.GroupedRow -import de.jeanlucmakiola.floret.components.OptionPicker import de.jeanlucmakiola.floret.components.Position -import de.jeanlucmakiola.floret.locale.currentLocale -import kotlin.math.roundToInt +import de.jeanlucmakiola.floret.components.GroupedSectionHeader +import de.jeanlucmakiola.floret.components.positionOf +import de.jeanlucmakiola.calendula.widget.config.PlacedWidget +import de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity +import de.jeanlucmakiola.calendula.widget.WidgetKind +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import de.jeanlucmakiola.calendula.widget.config.backgroundLabel +import de.jeanlucmakiola.calendula.widget.config.placedWidgets +import de.jeanlucmakiola.calendula.widget.config.themeLabel /** - * Widgets & tiles (#69): the home-screen widgets' settings and the Quick - * Settings tile shortcut. The agenda widget keeps its own range and text size, - * separate from the Agenda screen's range in [ViewsScreen]. + * Widgets & tiles (#69): the placed home-screen widgets, each opening its own + * config screen (#292), and the Quick Settings tile shortcut. */ @Composable internal fun WidgetsScreen( - state: SettingsUiState, - viewModel: SettingsViewModel, onBack: () -> Unit, visible: Boolean = true, ) { - var showAgendaWidgetRange by remember { mutableStateOf(false) } - var showWidgetSize by remember { mutableStateOf(false) } - CollapsingScaffold( title = stringResource(R.string.settings_section_widgets), onBack = onBack, predictiveBack = true, visible = visible, ) { - GroupedHint(stringResource(R.string.settings_widgets_hint)) - Spacer(Modifier.height(8.dp)) - - GroupedRow( - title = stringResource(R.string.settings_agenda_widget_range), - 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 }, + GroupedHint( + stringResource( + // The launcher's "Edit widget" action only exists from Android 12. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) R.string.settings_widgets_hint + else R.string.settings_widgets_hint_legacy, + ), ) + GroupedSectionHeader(stringResource(R.string.settings_widgets_placed)) + PlacedWidgetRows() // 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. @@ -76,30 +68,44 @@ internal fun WidgetsScreen( QuickSettingsTileRow() } } +} - if (showAgendaWidgetRange) { - AgendaRangePicker( - title = stringResource(R.string.settings_agenda_widget_range), - description = stringResource(R.string.settings_agenda_widget_range_hint), - selected = state.agendaWidgetRange, - weekStart = state.weekStart.resolveFirstDay(currentLocale()), - onSelect = viewModel::setAgendaWidgetRange, - onDismiss = { showAgendaWidgetRange = false }, - ) +@Composable +private fun PlacedWidgetRows() { + val context = LocalContext.current + // Read on every resume, the first included, so a widget added, removed or + // edited meanwhile shows up. + var refresh by remember { mutableIntStateOf(0) } + LifecycleResumeEffect(Unit) { + refresh++ + onPauseOrDispose {} } - if (showWidgetSize) { - OptionPicker( - title = stringResource(R.string.settings_widget_size), - header = { GroupedHint(stringResource(R.string.settings_widget_size_hint), verticalPadding = 8.dp) }, - predictiveBack = true, - options = WidgetSize.entries, - selected = state.widgetSize, - label = { widgetSizeLabel(it) }, - // No live preview: the widget is Glance/RemoteViews, so a Compose - // mock-up would be a second implementation free to drift. - summary = { widgetSizeSummary(it) }, - onSelect = viewModel::setWidgetSize, - onDismiss = { showWidgetSize = false }, + val widgets by produceState?>(initialValue = null, refresh) { + if (refresh > 0) value = withContext(Dispatchers.IO) { context.placedWidgets() } + } + val placed = widgets ?: return + if (placed.isEmpty()) { + GroupedRow( + title = stringResource(R.string.settings_widgets_none), + position = Position.Alone, + dimmed = true, + ) + return + } + val kindCounts = placed.groupingBy { it.kind }.eachCount() + val kindSeen = mutableMapOf() + placed.forEachIndexed { index, widget -> + val number = kindSeen.merge(widget.kind, 1, Int::plus)!! + val label = stringResource(widget.kind.label) + GroupedRow( + title = if (kindCounts.getValue(widget.kind) > 1) stringResource(R.string.widget_numbered, label, number) else label, + summary = stringResource( + R.string.widget_appearance_summary, + backgroundLabel(widget.appearance.background), + themeLabel(widget.appearance.theme), + ), + position = positionOf(index, placed.size), + onClick = { context.startActivity(WidgetConfigActivity.intent(context, widget.appWidgetId)) }, ) } } @@ -131,24 +137,3 @@ private fun requestAddQsTile(context: Context) { context.mainExecutor, ) { /* 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 - }, -) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/CalendulaWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/CalendulaWidgetReceiver.kt new file mode 100644 index 0000000..dbc12e0 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/CalendulaWidgetReceiver.kt @@ -0,0 +1,44 @@ +package de.jeanlucmakiola.calendula.widget + +import android.annotation.SuppressLint +import android.appwidget.AppWidgetManager +import android.content.Context +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import de.jeanlucmakiola.floret.glance.reloadOnUpdate +import de.jeanlucmakiola.floret.glance.restoreState + +/** + * What every widget receiver shares: the midnight rollover follows whether any + * widget is placed (#228), a restore keeps each widget's settings (#292), and + * the system's `updatePeriodMillis` wake-up reloads the data and re-arms the + * alarm — anything that drops a pending alarm without a broadcast (force-stop, + * battery restriction, an OEM freeze) is repaired there rather than on the + * next app open. + */ +abstract class CalendulaWidgetReceiver : GlanceAppWidgetReceiver() { + + override fun onEnabled(context: Context) { + super.onEnabled(context) + syncWidgetRollover(context) + } + + override fun onDisabled(context: Context) { + super.onDisabled(context) + syncWidgetRollover(context) + } + + override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) { + restoreState(context, oldWidgetIds, newWidgetIds) + } + + @SuppressLint("MissingSuperCall") + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + ) { + // In place of super, which only recomposes and would take the goAsync handle. + reloadOnUpdate(context, appWidgetIds) + syncWidgetRollover(context) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetConfig.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetConfig.kt new file mode 100644 index 0000000..32258b8 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetConfig.kt @@ -0,0 +1,105 @@ +package de.jeanlucmakiola.calendula.widget + +import android.content.Context +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey +import androidx.glance.GlanceId +import androidx.glance.appwidget.state.getAppWidgetState +import androidx.glance.state.PreferencesGlanceStateDefinition +import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange +import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange +import de.jeanlucmakiola.calendula.ui.agenda.storageValue +import de.jeanlucmakiola.floret.glance.WidgetBackground +import de.jeanlucmakiola.floret.glance.WidgetThemeMode +import de.jeanlucmakiola.floret.prefs.ThemeMode +import de.jeanlucmakiola.floret.prefs.toEnum + +/** A widget's own light/dark choice (#292); [FOLLOW_APP] tracks the app's theme setting. */ +enum class WidgetTheme { FOLLOW_APP, LIGHT, DARK } + +/** How a widget looks, set per placed widget in its config screen (#292). */ +data class WidgetAppearance( + val background: WidgetBackground = WidgetBackground.TINTED, + val theme: WidgetTheme = WidgetTheme.FOLLOW_APP, +) + +/** What the agenda widget lists, per placed widget. */ +data class AgendaWidgetOptions( + val range: AgendaRange, + val textSize: WidgetSize, + val showToday: Boolean, + val pastDisplay: PastEventDisplay, +) + +internal fun WidgetTheme.resolve(app: ThemeMode): WidgetThemeMode = when (this) { + WidgetTheme.LIGHT -> WidgetThemeMode.LIGHT + WidgetTheme.DARK -> WidgetThemeMode.DARK + WidgetTheme.FOLLOW_APP -> when (app) { + ThemeMode.SYSTEM -> WidgetThemeMode.SYSTEM + ThemeMode.LIGHT -> WidgetThemeMode.LIGHT + ThemeMode.DARK, ThemeMode.BLACK -> WidgetThemeMode.DARK + } +} + +private val BackgroundKey = stringPreferencesKey("cfg_background") +private val ThemeKey = stringPreferencesKey("cfg_theme") +private val AgendaRangeKey = stringPreferencesKey("cfg_agenda_range") +private val TextSizeKey = stringPreferencesKey("cfg_text_size") +private val ShowTodayKey = booleanPreferencesKey("cfg_show_today") +private val PastDisplayKey = stringPreferencesKey("cfg_past_display") +private val HiddenCalendarsKey = stringSetPreferencesKey("cfg_hidden_calendars") + +internal val Preferences.widgetAppearance: WidgetAppearance + get() = WidgetAppearance( + background = this[BackgroundKey].toEnum(WidgetBackground.TINTED), + theme = this[ThemeKey].toEnum(WidgetTheme.FOLLOW_APP), + ) + +/** + * This widget's agenda options. A widget placed before per-widget options + * existed has none stored, and keeps following the app-wide values in + * [defaults] until it is configured. + */ +internal fun Preferences.agendaWidgetOptions(defaults: AgendaWidgetOptions) = AgendaWidgetOptions( + range = parseAgendaRange(this[AgendaRangeKey], defaults.range), + textSize = this[TextSizeKey].toEnum(defaults.textSize), + showToday = this[ShowTodayKey] ?: defaults.showToday, + pastDisplay = this[PastDisplayKey].toEnum(defaults.pastDisplay), +) + +/** Whether this widget has agenda options of its own, rather than following the app's. */ +internal val Preferences.hasAgendaOptions: Boolean get() = this[AgendaRangeKey] != null + +internal fun MutablePreferences.putAppearance(appearance: WidgetAppearance) { + this[BackgroundKey] = appearance.background.name + this[ThemeKey] = appearance.theme.name +} + +internal fun MutablePreferences.putAgendaOptions(options: AgendaWidgetOptions) { + this[AgendaRangeKey] = options.range.storageValue() + this[TextSizeKey] = options.textSize.name + this[ShowTodayKey] = options.showToday + this[PastDisplayKey] = options.pastDisplay.name +} + +/** + * Calendars this widget leaves out, on top of the ones hidden app-wide. Stored + * as the excluded set, so a calendar added later shows up by default. + */ +internal val Preferences.hiddenCalendars: Set + get() = this[HiddenCalendarsKey]?.mapNotNull { it.toLongOrNull() }?.toSet().orEmpty() + +internal fun MutablePreferences.putHiddenCalendars(ids: Set) { + this[HiddenCalendarsKey] = ids.map(Long::toString).toSet() +} + +internal fun List.withoutCalendars(hidden: Set): List = + if (hidden.isEmpty()) this else filterNot { it.calendarId in hidden } + +internal suspend fun Context.widgetState(id: GlanceId): Preferences = + getAppWidgetState(this, PreferencesGlanceStateDefinition, id) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt index e902b59..cc99c55 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt @@ -4,7 +4,12 @@ import android.Manifest import android.content.Context import android.content.pm.PackageManager import androidx.core.content.ContextCompat +import androidx.glance.GlanceId 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.resolveFirstDay 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.dayCount 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.map import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate @@ -25,6 +35,7 @@ import kotlinx.datetime.plus import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime import java.util.Locale +import java.util.concurrent.ConcurrentHashMap import kotlin.time.Clock import kotlin.time.Instant @@ -34,10 +45,63 @@ import kotlin.time.Instant * the whole window, and on slow devices a wide one held the widget on "loading" * (#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(val content: T, val appTheme: ThemeMode) + +internal val SettingsPrefs.widgetSettings: Flow + 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() +/** + * The locale widget text is written in. Glance has no LocalConfiguration, and a + * widget is redrawn on a locale change anyway (LOCALE_CHANGED), so a plain read is enough. + */ +internal fun widgetLocale(): Locale = Locale.getDefault() + internal fun today(zone: TimeZone): LocalDate = Clock.System.now().toLocalDateTime(zone).date @@ -51,112 +115,74 @@ sealed interface AgendaWidgetData { data object NeedsPermission : AgendaWidgetData data class Ready( val today: LocalDate, - /** - * 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. - */ + /** Days with events across the configured range, unfiltered; hiding finished ones happens in the composition. */ val days: List, /** Resolved clock convention for event time labels (the time-format pref). */ val is24Hour: Boolean, /** Whether event colours are softened to pastels, or shown raw (#36). */ val soften: Boolean, - /** First day of the week, for the calendar-aligned "this week" range. */ - val weekStart: DayOfWeek, - /** Saved range pref — the fallback when an instance has no Glance state yet. */ - val savedRange: AgendaRange, - /** 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, + val pastDisplay: PastEventDisplay, + /** "Always show today" (#35): anchor today at the top even with no events left. */ + val showToday: Boolean, + val textSize: WidgetSize, /** Snapshot instant the data was read at, for "has this event ended?" tests. */ val now: Instant, ) : AgendaWidgetData } /** - * Source data for the month widget: a wide window of instances plus the - * week-start preference and today. The widget computes each displayed month's - * grid from this in-memory list (via `layoutMonthWeeks`) as the user pages, - * so month navigation is pure recomposition — no reload, no flaky widget - * session restart. + * Source data for the month widget: a window of instances plus the week-start + * preference and today. The widget computes each displayed month's grid from + * this in-memory list (via `layoutMonthWeeks`) as the user pages, so paging + * inside the loaded window is pure recomposition. */ sealed interface MonthWidgetSource { data object NeedsPermission : MonthWidgetSource data class Ready( val today: LocalDate, val weekStart: DayOfWeek, + val soften: Boolean, val instances: List, /** Month indices (`year * 12 + monthOrdinal`) whose grids [instances] covers. */ val months: IntRange, ) : MonthWidgetSource } -/** - * Process-lived cache of the month window. Month navigation can re-run - * `provideGlance`, and re-querying on every arrow tap is what made paging feel - * sluggish — so we load once and reuse the same snapshot for every nearby month. Invalidated by - * [invalidateMonthWidgetCache] when calendar data changes (the freshness - * 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 +/** [loadAgendaWidgetData] with agenda widget [id]'s own options. */ +internal suspend fun Context.loadAgendaWidget(id: GlanceId): Themed { + val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first() + val state = widgetState(id) + val options = state.agendaWidgetOptions(settings.agendaDefaults) + return Themed(loadAgendaWidgetData(settings, options, state.hiddenCalendars), settings.themeMode) } /** * One-shot read of the upcoming agenda for the widget. Reuses the app's * [agendaRange] window and [groupAgendaDays] grouping, and the repository's * [first]-emitted snapshot already has hidden calendars filtered out. - * - * Loads [range] (the widget's own, or the saved pref when null) rather than the - * widest selectable window (#363). */ -internal suspend fun Context.loadAgendaWidgetData(range: AgendaRange? = null): AgendaWidgetData { +internal suspend fun Context.loadAgendaWidgetData( + settings: WidgetSettings, + options: AgendaWidgetOptions, + hiddenCalendars: Set, +): AgendaWidgetData { if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission val zone = systemZone() val anchor = today(zone) val ep = widgetEntryPoint() - val prefs = ep.settingsPrefs() - val savedRange = prefs.agendaWidgetRange.first() - 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 weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()) + val dayCount = options.range.dayCount(anchor, weekStart) val window = agendaRange(anchor, dayCount - 1, zone) val windowEnd = anchor.plus(dayCount - 1, DateTimeUnit.DAY) - val instances = ep.calendarRepository().instances(window).first() - val is24Hour = prefs.timeFormat.first() - .is24Hour(android.text.format.DateFormat.is24HourFormat(this)) - val soften = prefs.softenCalendarColors.first() + val instances = ep.calendarRepository().instances(window).first().withoutCalendars(hiddenCalendars) return AgendaWidgetData.Ready( today = anchor, days = groupAgendaDays(anchor, windowEnd, instances, zone), - is24Hour = is24Hour, - soften = soften, - weekStart = weekStart, - savedRange = savedRange, - savedPastDisplay = savedPastDisplay, - savedShowToday = showToday, - savedWidgetSize = prefs.widgetSize.first(), - loadedThrough = windowEnd, + is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)), + soften = settings.soften, + pastDisplay = options.pastDisplay, + showToday = options.showToday, + textSize = options.textSize, now = Clock.System.now(), ) } @@ -165,17 +191,55 @@ internal fun monthIndexOf(date: LocalDate): Int = date.year * 12 + date.month.or private fun firstDayOfMonthIndex(index: Int): LocalDate = LocalDate(index / 12, index % 12 + 1, 1) -/** 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) + + private val entries = ConcurrentHashMap() + + fun get(id: GlanceId, center: Int, stamp: Long): Themed? = + 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) { + 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 = + MonthSourceCache.get(id, centerIndex, stamp) + ?: loadThemedMonthSource(centerIndex, widgetState(id).hiddenCalendars) + .also { MonthSourceCache.put(id, centerIndex, stamp, it) } + +/** Read backing the month widget's grid for the window around [centerIndex], with the app theme. */ +internal suspend fun Context.loadThemedMonthSource(centerIndex: Int, hiddenCalendars: Set): Themed { + val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first() + return Themed(queryMonthSource(centerIndex, settings, hiddenCalendars), settings.themeMode) +} + +private suspend fun Context.queryMonthSource( + centerIndex: Int, + settings: WidgetSettings, + hiddenCalendars: Set, +): MonthWidgetSource { if (!hasCalendarPermission()) return MonthWidgetSource.NeedsPermission 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 weekStart = ep.settingsPrefs().weekStart.first().resolveFirstDay(Locale.getDefault()) - val months = (center - MONTH_WIDGET_PAD_MONTHS)..(center + MONTH_WIDGET_PAD_MONTHS) + val months = (centerIndex - MONTH_WIDGET_PAD_MONTHS)..(centerIndex + MONTH_WIDGET_PAD_MONTHS) // Padded by the grid's spill into the neighbouring months. val from = firstDayOfMonthIndex(months.first) .minus(1, DateTimeUnit.WEEK) @@ -184,11 +248,79 @@ internal suspend fun Context.loadMonthWidgetSource(centerIndex: Int? = null): Mo .plus(2, DateTimeUnit.WEEK) .atTime(23, 59, 59) .toInstant(zone) - val instances = ep.calendarRepository().instances(from..to).first() return MonthWidgetSource.Ready( - today = anchor, - weekStart = weekStart, - instances = instances, + today = today(zone), + weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()), + soften = settings.soften, + instances = ep.calendarRepository().instances(from..to).first().withoutCalendars(hiddenCalendars), months = months, - ).also { MonthWidgetCache.data = it } + ) +} + +/** Today and the app theme, for a widget that draws no events. */ +internal suspend fun Context.loadToday(): Themed = + Themed(today(systemZone()), widgetEntryPoint().settingsPrefs().widgetSettings.first().themeMode) + +/** What a widget drawing a run of days loads: those days' events, grouped like the agenda. */ +sealed interface DaysWidgetData { + data object NeedsPermission : DaysWidgetData + data class Ready( + val today: LocalDate, + val firstDay: LocalDate, + val lastDay: LocalDate, + /** Only the days with events, in order. */ + val days: List, + val weekStart: DayOfWeek, + val is24Hour: Boolean, + val soften: Boolean, + val options: AgendaWidgetOptions, + val now: Instant, + ) : DaysWidgetData { + fun eventsOn(date: LocalDate): List = days.firstOrNull { it.date == date }?.events.orEmpty() + } +} + +/** Widget [id]'s stored options and calendars, with the app's values for what it has none of. */ +internal class WidgetLoadContext(val settings: WidgetSettings, val options: AgendaWidgetOptions, val hiddenCalendars: Set) + +internal suspend fun Context.widgetLoadContext(id: GlanceId): WidgetLoadContext { + val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first() + val state = widgetState(id) + return WidgetLoadContext(settings, state.agendaWidgetOptions(settings.agendaDefaults), state.hiddenCalendars) +} + +/** The events on the days [days] picks from today and the week start, for [load]'s widget. */ +internal suspend fun Context.loadDays( + load: WidgetLoadContext, + days: (today: LocalDate, weekStart: DayOfWeek) -> ClosedRange, +): Themed { + val settings = load.settings + if (!hasCalendarPermission()) return Themed(DaysWidgetData.NeedsPermission, settings.themeMode) + val zone = systemZone() + val anchor = today(zone) + val weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()) + val range = days(anchor, weekStart) + val window = range.start.atStartOfDayIn(zone)..range.endInclusive.atTime(23, 59, 59).toInstant(zone) + val instances = widgetEntryPoint().calendarRepository().instances(window).first().withoutCalendars(load.hiddenCalendars) + return Themed( + DaysWidgetData.Ready( + today = anchor, + firstDay = range.start, + lastDay = range.endInclusive, + days = groupAgendaDays(range.start, range.endInclusive, instances, zone), + weekStart = weekStart, + is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)), + soften = settings.soften, + options = load.options, + now = Clock.System.now(), + ), + settings.themeMode, + ) +} + +/** The week [today] falls in, from [weekStart]. */ +internal fun weekOf(today: LocalDate, weekStart: DayOfWeek): ClosedRange { + val back = (today.dayOfWeek.ordinal - weekStart.ordinal + 7) % 7 + val first = today.minus(back, DateTimeUnit.DAY) + return first..first.plus(6, DateTimeUnit.DAY) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEntryPoint.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEntryPoint.kt index 177576c..e509067 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEntryPoint.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEntryPoint.kt @@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs interface WidgetEntryPoint { fun calendarRepository(): CalendarRepository fun settingsPrefs(): SettingsPrefs + fun widgetRefreshCoordinator(): WidgetRefreshCoordinator } internal fun Context.widgetEntryPoint(): WidgetEntryPoint = diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEventRow.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEventRow.kt new file mode 100644 index 0000000..3e595d9 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEventRow.kt @@ -0,0 +1,114 @@ +package de.jeanlucmakiola.calendula.widget + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.LocalContext +import androidx.glance.action.clickable +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.layout.Alignment +import androidx.glance.layout.Column +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import de.jeanlucmakiola.calendula.MainActivity +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.isDeclined +import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha +import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA +import de.jeanlucmakiola.calendula.ui.common.eventFill +import de.jeanlucmakiola.calendula.ui.common.eventInk +import de.jeanlucmakiola.calendula.widget.agenda.metricsFor +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.glance.WidgetGroupedRow +import de.jeanlucmakiola.floret.glance.themedColor + +/** Text and row sizes of a compact event list, from a widget's [WidgetSize]. */ +internal data class EventRowMetrics(val title: TextUnit, val time: TextUnit, val rowHeight: Dp, val message: TextUnit) + +/** The agenda widget's sizes with tighter rows, resolved against the system font scale. */ +internal fun compactRowMetrics(size: WidgetSize, fontScale: Float): EventRowMetrics { + val agenda = metricsFor(size) + return EventRowMetrics( + title = agenda.eventTitle, + time = agenda.eventTime, + rowHeight = agenda.contentH * fontScale + agenda.rowVPad, + message = agenda.message, + ) +} + +/** + * One event as a two-line row in its calendar's fill, part of a grouped card + * at [position]; opens the occurrence. [quiet] draws it in the card colour + * instead, for events shown only as a look ahead. + */ +@Composable +internal fun WidgetEventRow( + event: EventInstance, + secondary: String, + soften: Boolean, + metrics: EventRowMetrics, + position: Position, + source: CalendarView, + dimmed: Boolean = false, + quiet: Boolean = false, +) { + val context = LocalContext.current + fun tone(dark: Boolean): Color = eventFill(event.color, dark, soften).let { if (dimmed) it.copy(alpha = EventDimAlpha) else it } + val day = tone(dark = false) + val night = tone(dark = true) + // Ink is picked against the opaque fill, then carries the dim along with it. + fun ink(fill: Color, alpha: Float) = eventInk(fill.copy(alpha = 1f), alpha * fill.alpha) + val card = widgetCardColor() + WidgetGroupedRow(position = position, color = if (quiet) card else themedColor(day = day, night = night)) { + Column( + modifier = GlanceModifier + .fillMaxWidth() + .height(metrics.rowHeight) + .padding(horizontal = 12.dp) + .clickable( + actionStartActivity( + MainActivity.openEventIntent( + context = context, + eventId = event.eventId, + beginMillis = event.start.toEpochMilliseconds(), + endMillis = event.end.toEpochMilliseconds(), + source = source, + ), + ), + ), + verticalAlignment = Alignment.CenterVertically, + ) { + val titleInk = if (quiet) { + GlanceTheme.colors.onSurface + } else { + themedColor(day = ink(day, 1f), night = ink(night, 1f)) + } + val timeInk = if (quiet) { + GlanceTheme.colors.onSurfaceVariant + } else { + themedColor(day = ink(day, SECONDARY_INK_ALPHA), night = ink(night, SECONDARY_INK_ALPHA)) + } + Text( + text = event.title.ifBlank { context.getString(R.string.event_untitled) }, + maxLines = 1, + style = TextStyle( + color = titleInk, + fontSize = metrics.title, + fontWeight = FontWeight.Medium, + textDecoration = glanceDeclinedDecoration(event.isDeclined), + ), + ) + Text(text = secondary, maxLines = 1, style = TextStyle(color = timeInk, fontSize = metrics.time)) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetKind.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetKind.kt new file mode 100644 index 0000000..41755f4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetKind.kt @@ -0,0 +1,120 @@ +package de.jeanlucmakiola.calendula.widget + +import android.appwidget.AppWidgetManager +import android.content.Context +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.glance.appwidget.GlanceAppWidget +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget +import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver +import de.jeanlucmakiola.calendula.widget.month.MonthWidget +import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver +import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidget +import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetReceiver +import de.jeanlucmakiola.calendula.widget.date.DateWidget +import de.jeanlucmakiola.calendula.widget.date.DateWidgetReceiver +import de.jeanlucmakiola.calendula.widget.week.WeekStripWidget +import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetReceiver +import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidget +import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetReceiver +import de.jeanlucmakiola.calendula.widget.today.TodayWidget +import de.jeanlucmakiola.calendula.widget.today.TodayWidgetReceiver +import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidget +import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetReceiver +import de.jeanlucmakiola.calendula.widget.split.MonthDayWidget +import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetReceiver + +/** A content option a widget's config screen offers, besides background and theme. */ +enum class WidgetOption { RANGE, TEXT_SIZE, PAST_DISPLAY, SHOW_TODAY, CALENDARS } + +/** + * Every widget the app ships, told apart by their receiver: what redraws, + * the rollover and the config screen go through. + */ +enum class WidgetKind( + val receiver: Class<*>, + val label: Int, + val options: Set, + /** The size a config preview draws at before the launcher reports one. */ + val defaultSize: DpSize, +) { + AGENDA( + AgendaWidgetReceiver::class.java, + R.string.widget_agenda_label, + setOf(WidgetOption.RANGE, WidgetOption.TEXT_SIZE, WidgetOption.PAST_DISPLAY, WidgetOption.SHOW_TODAY, WidgetOption.CALENDARS), + DpSize(260.dp, 280.dp), + ), + MONTH( + MonthWidgetReceiver::class.java, + R.string.widget_month_label, + setOf(WidgetOption.CALENDARS), + DpSize(320.dp, 340.dp), + ), + QUICK_ADD( + QuickAddWidgetReceiver::class.java, + R.string.widget_quick_add_label, + emptySet(), + DpSize(76.dp, 76.dp), + ), + DATE( + DateWidgetReceiver::class.java, + R.string.widget_date_label, + emptySet(), + DpSize(76.dp, 76.dp), + ), + WEEK_STRIP( + WeekStripWidgetReceiver::class.java, + R.string.widget_week_strip_label, + setOf(WidgetOption.CALENDARS), + DpSize(300.dp, 90.dp), + ), + UPCOMING( + UpcomingWidgetReceiver::class.java, + R.string.widget_upcoming_label, + setOf(WidgetOption.RANGE, WidgetOption.TEXT_SIZE, WidgetOption.CALENDARS), + DpSize(180.dp, 180.dp), + ), + TODAY( + TodayWidgetReceiver::class.java, + R.string.widget_today_label, + setOf(WidgetOption.TEXT_SIZE, WidgetOption.PAST_DISPLAY, WidgetOption.CALENDARS), + DpSize(300.dp, 100.dp), + ), + COUNTDOWN( + CountdownWidgetReceiver::class.java, + R.string.widget_countdown_label, + setOf(WidgetOption.CALENDARS), + DpSize(200.dp, 100.dp), + ), + MONTH_DAY( + MonthDayWidgetReceiver::class.java, + R.string.widget_month_day_label, + setOf(WidgetOption.TEXT_SIZE, WidgetOption.PAST_DISPLAY, WidgetOption.CALENDARS), + DpSize(320.dp, 200.dp), + ), + ; + + fun widget(): GlanceAppWidget = when (this) { + AGENDA -> AgendaWidget() + MONTH -> MonthWidget() + QUICK_ADD -> QuickAddWidget() + DATE -> DateWidget() + WEEK_STRIP -> WeekStripWidget() + UPCOMING -> UpcomingWidget() + TODAY -> TodayWidget() + COUNTDOWN -> CountdownWidget() + MONTH_DAY -> MonthDayWidget() + } + + /** Whether this kind stores [AgendaWidgetOptions] of its own. */ + val hasContentOptions: Boolean + get() = options.any { it != WidgetOption.CALENDARS } + + companion object { + fun of(context: Context, appWidgetId: Int): WidgetKind? { + val provider = AppWidgetManager.getInstance(context)?.getAppWidgetInfo(appWidgetId)?.provider ?: return null + return entries.firstOrNull { it.receiver.name == provider.className } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetParts.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetParts.kt new file mode 100644 index 0000000..e16193b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetParts.kt @@ -0,0 +1,55 @@ +package de.jeanlucmakiola.calendula.widget + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.glance.GlanceTheme +import androidx.glance.unit.ColorProvider +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.agenda.AgendaTimeLabel +import de.jeanlucmakiola.calendula.ui.agenda.agendaTimeLabel +import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay +import de.jeanlucmakiola.floret.glance.LocalWidgetBackground +import de.jeanlucmakiola.floret.glance.WidgetBackground +import de.jeanlucmakiola.floret.locale.localizedDateFormatter +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.plus +import kotlinx.datetime.toJavaDayOfWeek +import kotlinx.datetime.toJavaLocalDate +import kotlinx.datetime.toLocalDateTime +import java.util.Locale +import kotlin.time.Instant + +/** Cards sit one tone off the root fill, and stay opaque over a wallpaper (#292). */ +@Composable +internal fun widgetCardColor(): ColorProvider = when (LocalWidgetBackground.current) { + WidgetBackground.SOLID -> GlanceTheme.colors.surfaceVariant + WidgetBackground.TINTED, WidgetBackground.TRANSPARENT -> GlanceTheme.colors.widgetBackground +} + +/** An event's time on [day]: "All day", "Starts 14:00", "Ends 9:00" or "14:00 – 15:30". */ +internal fun widgetTimeLabel(context: Context, event: EventInstance, day: LocalDate, is24Hour: Boolean): String = + when (val label = agendaTimeLabel(event, day, systemZone())) { + AgendaTimeLabel.AllDay -> context.getString(R.string.event_detail_all_day) + is AgendaTimeLabel.Starts -> context.getString(R.string.agenda_span_starts, formatWidgetTime(label.start, is24Hour)) + is AgendaTimeLabel.Ends -> context.getString(R.string.agenda_span_ends, formatWidgetTime(label.end, is24Hour)) + is AgendaTimeLabel.Range -> "${formatWidgetTime(label.start, is24Hour)} – ${formatWidgetTime(label.end, is24Hour)}" + } + +internal fun formatWidgetTime(instant: Instant, is24Hour: Boolean): String { + val t = instant.toLocalDateTime(systemZone()).time + return formatTimeOfDay(t.hour, t.minute, is24Hour, Locale.getDefault()) +} + +/** "Today", "Tomorrow", or the short weekday within the coming week, else the short date. */ +internal fun relativeDayLabel(context: Context, date: LocalDate, today: LocalDate): String { + val locale = Locale.getDefault() + return when { + date == today -> context.getString(R.string.agenda_header_today) + date == today.plus(1, DateTimeUnit.DAY) -> context.getString(R.string.agenda_header_tomorrow) + date < today.plus(7, DateTimeUnit.DAY) && date > today -> + date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, locale) + else -> localizedDateFormatter(locale, "dMMM").format(date.toJavaLocalDate()) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetPickerPreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetPickerPreview.kt new file mode 100644 index 0000000..b2a4788 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetPickerPreview.kt @@ -0,0 +1,177 @@ +package de.jeanlucmakiola.calendula.widget + +import android.content.Context +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.provideContent +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.data.prefs.is24Hour +import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays +import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetContent +import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver +import de.jeanlucmakiola.calendula.widget.countdown.CountdownData +import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions +import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetContent +import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetReceiver +import de.jeanlucmakiola.calendula.widget.date.DateWidgetContent +import de.jeanlucmakiola.calendula.widget.date.DateWidgetReceiver +import de.jeanlucmakiola.calendula.widget.month.MonthWidgetContent +import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver +import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetContent +import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetReceiver +import de.jeanlucmakiola.calendula.widget.split.MonthDayData +import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetContent +import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetReceiver +import de.jeanlucmakiola.calendula.widget.today.TodayWidgetContent +import de.jeanlucmakiola.calendula.widget.today.TodayWidgetReceiver +import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetContent +import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetReceiver +import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetContent +import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetReceiver +import de.jeanlucmakiola.floret.glance.publishWidgetPreviews +import kotlinx.coroutines.flow.first +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.atTime +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import java.util.Locale +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +/** Republishes the generated picker previews (Android 15+) after an app update or a language change. */ +internal suspend fun publishCalendulaWidgetPreviews(context: Context) { + publishWidgetPreviews( + context, + version = "${context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime}-${Locale.getDefault().toLanguageTag()}", + receivers = listOf( + AgendaWidgetReceiver::class, + MonthWidgetReceiver::class, + QuickAddWidgetReceiver::class, + DateWidgetReceiver::class, + WeekStripWidgetReceiver::class, + UpcomingWidgetReceiver::class, + TodayWidgetReceiver::class, + CountdownWidgetReceiver::class, + MonthDayWidgetReceiver::class, + ), + ) +} + +/** The size [kind]'s picker preview is laid out at. */ +internal fun previewSizeOf(kind: WidgetKind) = SizeMode.Responsive(setOf(kind.defaultSize)) + +/** [kind]'s picker preview: the real widget, drawn with sample events around today's morning. */ +internal suspend fun GlanceAppWidget.provideSamplePreview(context: Context, kind: WidgetKind) { + val settings = context.widgetEntryPoint().settingsPrefs().widgetSettings.first() + val zone = systemZone() + val today = today(zone) + val now = today.atTime(8, 0).toInstant(zone) + val instances = context.sampleInstances(today, zone) + val weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()) + val is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context)) + val options = settings.agendaDefaults + val appearance = WidgetAppearance() + val index = monthIndexOf(today) + val month = MonthWidgetSource.Ready(today, weekStart, settings.soften, instances, index..index) + fun days(range: ClosedRange) = DaysWidgetData.Ready( + today = today, + firstDay = range.start, + lastDay = range.endInclusive, + days = groupAgendaDays(range.start, range.endInclusive, instances, zone), + weekStart = weekStart, + is24Hour = is24Hour, + soften = settings.soften, + options = options, + now = now, + ) + val theme = settings.themeMode + provideContent { + when (kind) { + WidgetKind.AGENDA -> AgendaWidgetContent( + AgendaWidgetData.Ready( + today = today, + days = groupAgendaDays(today, today.plus(6, DateTimeUnit.DAY), instances, zone), + is24Hour = is24Hour, + soften = settings.soften, + pastDisplay = options.pastDisplay, + showToday = options.showToday, + textSize = options.textSize, + now = now, + ), + appearance, + theme, + preview = true, + ) + WidgetKind.MONTH -> MonthWidgetContent(month, index, appearance, theme) + WidgetKind.MONTH_DAY -> MonthDayWidgetContent( + MonthDayData.Ready(month, options, is24Hour, now), + index, + selected = null, + appearance = appearance, + appTheme = theme, + preview = true, + ) + WidgetKind.QUICK_ADD -> QuickAddWidgetContent(today, appearance, theme) + WidgetKind.DATE -> DateWidgetContent(today, appearance, theme) + WidgetKind.WEEK_STRIP -> WeekStripWidgetContent(days(weekOf(today, weekStart)), appearance, theme) + WidgetKind.UPCOMING -> UpcomingWidgetContent(days(today..today.plus(6, DateTimeUnit.DAY)), appearance, theme) + WidgetKind.TODAY -> TodayWidgetContent(days(today..today.plus(1, DateTimeUnit.DAY)), appearance, theme, preview = true) + WidgetKind.COUNTDOWN -> CountdownWidgetContent( + CountdownData.Ready( + options = CountdownOptions(), + event = instances.first { !it.isAllDay && it.start > now }, + now = now, + today = today, + is24Hour = is24Hour, + soften = settings.soften, + appWidgetId = null, + ), + appearance, + theme, + ) + } + } +} + +/** A plausible few weeks: a busy today, a trip tomorrow, and a scatter of days with something on. */ +private fun Context.sampleInstances(today: LocalDate, zone: TimeZone): List { + var id = 0L + fun timed(day: Int, at: LocalTime, length: kotlin.time.Duration, title: Int, color: Int): EventInstance { + val start = today.plus(day, DateTimeUnit.DAY).atTime(at).toInstant(zone) + id++ + return EventInstance(id, id, 1, getString(title), start, start + length, false, color, null) + } + fun allDay(day: Int, title: Int, color: Int): EventInstance { + val date = today.plus(day, DateTimeUnit.DAY) + id++ + return EventInstance( + id, id, 1, getString(title), + date.atStartOfDayIn(TimeZone.UTC), date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(TimeZone.UTC), + true, color, null, + ) + } + return listOf( + timed(0, LocalTime(9, 30), 30.minutes, R.string.settings_past_events_sample_morning, Blue), + timed(0, LocalTime(12, 30), 1.hours, R.string.settings_past_events_sample_midday, Green), + timed(0, LocalTime(19, 0), 2.hours, R.string.settings_past_events_sample_evening, Purple), + allDay(1, R.string.widget_preview_sample_trip, Orange), + timed(2, LocalTime(10, 0), 1.hours, R.string.widget_preview_sample_dentist, Red), + timed(-6, LocalTime(9, 30), 30.minutes, R.string.settings_past_events_sample_morning, Blue), + timed(-2, LocalTime(19, 0), 2.hours, R.string.settings_past_events_sample_evening, Purple), + timed(5, LocalTime(19, 0), 2.hours, R.string.settings_past_events_sample_evening, Purple), + timed(8, LocalTime(9, 30), 30.minutes, R.string.settings_past_events_sample_morning, Blue), + timed(12, LocalTime(12, 30), 1.hours, R.string.settings_past_events_sample_midday, Green), + ).sortedBy { it.start } +} + +private const val Blue = 0xFF039BE5.toInt() +private const val Green = 0xFF33B679.toInt() +private const val Purple = 0xFF8E24AA.toInt() +private const val Orange = 0xFFF4511E.toInt() +private const val Red = 0xFFD50000.toInt() diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt new file mode 100644 index 0000000..ec8bd90 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt @@ -0,0 +1,124 @@ +package de.jeanlucmakiola.calendula.widget + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.os.SystemClock +import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository +import de.jeanlucmakiola.calendula.data.di.ApplicationScope +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.floret.glance.WidgetRefresher +import de.jeanlucmakiola.floret.glance.WidgetRollover +import de.jeanlucmakiola.floret.glance.updatePlaced +import de.jeanlucmakiola.floret.glance.updateWithRefresh +import androidx.glance.appwidget.GlanceAppWidgetManager +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong +import javax.inject.Inject +import javax.inject.Singleton + +/** Every widget receiver; the set [WidgetRollover] checks for placed instances. */ +internal val WIDGET_RECEIVER_CLASSES: List> = WidgetKind.entries.map { it.receiver } + +/** The app's own midnight wake-up (#228). */ +internal const val ACTION_WIDGET_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER" + +/** Fixed: there is only ever one rollover alarm, and re-arming must replace it. */ +private const val ROLLOVER_REQUEST_CODE = 0x0DA1 + +internal fun widgetRolloverPendingIntent(context: Context): PendingIntent = PendingIntent.getBroadcast( + context, + ROLLOVER_REQUEST_CODE, + Intent(context, WidgetUpdateReceiver::class.java).setAction(ACTION_WIDGET_ROLLOVER), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, +) + +/** Arm the next midnight rollover, or cancel it when no widget is placed. Idempotent. */ +internal fun syncWidgetRollover(context: Context) = + WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent) + +/** + * Reloads and redraws every placed widget, a running session included: the + * refresh stamp moves, and every widget keys its data load on it (#420). + */ +internal suspend fun redrawWidgets(context: Context) { + WidgetKind.entries.forEach { redrawWidgets(context, it) } +} + +internal suspend fun redrawWidgets(context: Context, kind: WidgetKind) = + kind.widget().updatePlaced(context, kind.receiver, bumpRefreshStamp = true) + +/** Redraws only the widgets with no content options of their own, which still follow the app's. */ +private suspend fun redrawUnconfiguredWidgets(context: Context) { + val appContext = context.applicationContext + val manager = AppWidgetManager.getInstance(appContext) ?: return + val glanceManager = GlanceAppWidgetManager(appContext) + WidgetKind.entries.filter { it.hasContentOptions }.forEach { kind -> + manager.getAppWidgetIds(ComponentName(appContext, kind.receiver)).forEach { appWidgetId -> + val glanceId = glanceManager.getGlanceIdBy(appWidgetId) + if (!appContext.widgetState(glanceId).hasAgendaOptions) kind.widget().updateWithRefresh(appContext, glanceId) + } + } +} + +/** When the app last saw a calendar change of its own, for skipping the `PROVIDER_CHANGED` that echoes it. */ +private val lastLocalChange = AtomicLong(0L) + +private const val PROVIDER_ECHO_WINDOW_MILLIS = 5_000L + +internal fun isProviderEcho(): Boolean { + val last = lastLocalChange.get() + return last != 0L && SystemClock.elapsedRealtime() - last < PROVIDER_ECHO_WINDOW_MILLIS +} + +/** + * Redraws the widgets on the app's own calendar writes, visibility changes and + * the settings they draw with, so those don't wait for `PROVIDER_CHANGED`, + * which arrives seconds late or not at all (#420, #229). Changes from outside + * the app are [WidgetUpdateReceiver]'s. The app's agenda options only redraw + * widgets that have no content options of their own (#292). Started once from + * `CalendulaApp`. + */ +@Singleton +class WidgetRefreshCoordinator @Inject constructor( + @ApplicationContext private val context: Context, + private val repository: CalendarRepository, + private val prefs: SettingsPrefs, + @ApplicationScope private val scope: CoroutineScope, +) { + private val started = AtomicBoolean(false) + + /** Subscribes before returning, so a change made right after is never taken for the starting value. */ + fun start() { + if (!started.compareAndSet(false, true)) return + val all = WidgetKind.entries.map { kind -> WidgetRefresher(scope) { redrawWidgets(context, kind) } } + val unconfigured = WidgetRefresher(scope) { redrawUnconfiguredWidgets(context) } + watch(repository.localChanges) { + lastLocalChange.set(SystemClock.elapsedRealtime()) + all.forEach { it.request() } + } + watch(prefs.widgetSettings.map { it.shared }.distinctUntilChanged().drop(1)) { + all.forEach { it.request() } + } + watch(prefs.widgetSettings.map { it.agendaDefaults }.distinctUntilChanged().drop(1)) { + unconfigured.request() + } + } + + private fun watch(changes: Flow<*>, onChange: () -> Unit) { + scope.launch(Dispatchers.IO, start = CoroutineStart.UNDISPATCHED) { + changes.collect { onChange() } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverScheduler.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverScheduler.kt deleted file mode 100644 index b08c987..0000000 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverScheduler.kt +++ /dev/null @@ -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() ?: 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 -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt index d679fe5..8809be4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt @@ -1,39 +1,36 @@ package de.jeanlucmakiola.calendula.widget -import android.os.Build import androidx.compose.runtime.Composable -import androidx.glance.GlanceTheme -import androidx.glance.material3.ColorProviders import androidx.glance.text.TextDecoration import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback 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). - * Reuses the exact same hand-tuned schemes as the in-app theme - * ([CalendulaLightFallback] / [CalendulaDarkFallback]) so a widget on an older - * 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. + * Glance equivalent of `CalendulaTheme`: Material You on API 31+, the app's + * hand-tuned fallback schemes below that, in the light/dark [mode] the widget + * was configured with (#292). */ @Composable -fun CalendulaGlanceTheme(content: @Composable () -> Unit) { - val colors = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - GlanceTheme.colors - } else { - CalendulaGlanceColors +fun CalendulaGlanceTheme(mode: WidgetThemeMode, content: @Composable () -> Unit) { + FloretGlanceTheme( + lightScheme = CalendulaLightFallback, + darkScheme = CalendulaDarkFallback, + 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) } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt index b4972aa..45ddeb1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetUpdateReceiver.kt @@ -1,120 +1,29 @@ package de.jeanlucmakiola.calendula.widget -import android.content.BroadcastReceiver +import android.app.PendingIntent import android.content.Context import android.content.Intent -import android.os.SystemClock -import androidx.glance.appwidget.updateAll -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 +import de.jeanlucmakiola.floret.glance.FloretWidgetUpdateReceiver +import kotlin.time.Duration.Companion.minutes /** - * Redraws both home-screen widgets when their data goes stale, and keeps the - * midnight rollover alarm armed. Triggered by: - * - `PROVIDER_CHANGED` from the calendar provider — fires on any data change, - * so it covers both the app's own writes and external sync. - * - [ACTION_ROLLOVER], the app's own alarm from [WidgetRolloverScheduler] — - * the day boundary, so "today" highlighting and the agenda's past-event - * dimming move on (#228). - * - `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. + * Redraws both widgets when their data, day or language goes stale, and keeps + * the midnight rollover armed. `PROVIDER_CHANGED` covers changes made outside + * the app; the app's own writes go through [WidgetRefreshCoordinator]. A data + * change also re-arms, at most every 15 minutes: the inexact alarm can be + * dropped in a restricted standby bucket, and a calendar change is the one + * signal that keeps arriving. A `PROVIDER_CHANGED` right after one of the + * app's own changes only echoes it, so it doesn't redraw a second time. */ -class WidgetUpdateReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - // Exported, so anything can reach it with an explicit intent. Nothing - // here crosses a trust boundary, but narrowing to the actions we asked - // for keeps a stray broadcast from costing two wide provider reads. - 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() - } - } - } +class WidgetUpdateReceiver : FloretWidgetUpdateReceiver() { + override val rolloverAction = ACTION_WIDGET_ROLLOVER + override val widgetReceiverClasses = WIDGET_RECEIVER_CLASSES + override val extraHandledActions = setOf(Intent.ACTION_PROVIDER_CHANGED) + override val extraRearmInterval = 15.minutes - companion object { - /** The app's own midnight wake-up; see [WidgetRolloverScheduler]. */ - const val ACTION_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER" + override fun rolloverPendingIntent(context: Context): PendingIntent = widgetRolloverPendingIntent(context) - /** Both wipe pending alarms, and the host redraws the widgets itself after them. */ - private val REARM_ONLY_ACTIONS = setOf( - Intent.ACTION_BOOT_COMPLETED, - Intent.ACTION_MY_PACKAGE_REPLACED, - ) + override fun skipRedraw(action: String) = action == Intent.ACTION_PROVIDER_CHANGED && isProviderEcho() - /** - * 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) - } - } + override suspend fun redraw(context: Context) = redrawWidgets(context) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt index 3414741..8b07067 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScale.kt @@ -6,17 +6,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import de.jeanlucmakiola.calendula.widget.WidgetSize -/** - * Horizontal layout constants for an agenda event row. These don't scale with the - * size step, since a wider stripe or gap would eat title width, but [TEXT_INDENT] - * is derived from them so non-event text can't drift out of alignment. - */ -internal val ROW_H_PAD = 4.dp -internal val STRIPE_WIDTH = 5.dp -internal val STRIPE_GAP = 10.dp +/** Horizontal inset of a row's text inside its card; doesn't scale, so it never eats title width. */ +internal val ROW_H_PAD = 12.dp -/** Left indent that lines non-event text up with the event title column. */ -internal val TEXT_INDENT = ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP +/** Space between the date-pill gutter and a day's card. */ +internal val GUTTER_GAP = 6.dp /** * Every size the agenda widget varies by [WidgetSize]. Values that genuinely @@ -24,90 +18,91 @@ internal val TEXT_INDENT = ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP * constants rather than routing through here. */ internal data class AgendaMetrics( - val title: TextUnit, // header "Upcoming" - val dayHeader: TextUnit, // day label ("Today · …") + val pillWeekday: TextUnit, // date pill weekday ("Thu") + val pillDay: TextUnit, // date pill day number val eventTitle: TextUnit, // event title line val eventTime: TextUnit, // time/location line val placeholder: TextUnit, // "no more events today" (#35) val message: TextUnit, // empty/permission centred message - val stripeH: Dp, // coloured event stripe height - val iconImage: Dp, // header action icon glyph - val iconBox: Dp, // header action touch target + val contentH: Dp, // an event row's two text lines + val pillWidth: Dp, // date pill / gutter width val rowVPad: Dp, // event row vertical padding - val dayHeaderTopPad: Dp, // space above a day header + val dayGap: Dp, // space between two days' cards + val monthLabel: TextUnit, // month name above a new month's first day ) { + /** One event row's height, shared by the date pill beside it and the placeholder row. */ + val rowHeight: Dp get() = contentH + rowVPad * 2 + /** - * Resolves the stripe height against the user's system font scale: the stripe - * is a [Dp] while the lines beside it are `sp`, so without this the text - * outgrows it at large font settings. + * Resolves the row content height and pill width against the user's system font + * scale: both are [Dp] while the text in and beside them is `sp`, so without + * this the text outgrows them at large font settings. */ fun scaledForFont(fontScale: Float): AgendaMetrics = - if (fontScale == 1f) this else copy(stripeH = stripeH * fontScale) + if (fontScale == 1f) this else copy(contentH = contentH * fontScale, pillWidth = pillWidth * fontScale) } /* - * Type sizes are anchored to the Material 3 type scale, with two deviations: + * Type sizes are anchored to the Material 3 type scale, with one deviation: * - * ‡ SMALL's 13sp day header is off-scale, held there so SMALL reproduces the - * widget's original constants verbatim (#51). * † Above Title Medium the M3 scale jumps 16 → 22 → 24, too coarse for four * steps; interpolated values are used where a role would force a ≥1.4x jump. */ private val SMALL_METRICS = AgendaMetrics( - title = 16.sp, // M3 Title Medium - dayHeader = 13.sp, // ‡ off-scale, holds the #51 baseline + pillWeekday = 11.sp, + pillDay = 16.sp, eventTitle = 14.sp, // M3 Body Medium eventTime = 12.sp, // M3 Body Small placeholder = 14.sp, // M3 Body Medium message = 14.sp, // M3 Body Medium - stripeH = 36.dp, - iconImage = 22.dp, - iconBox = 40.dp, + contentH = 36.dp, + pillWidth = 40.dp, rowVPad = 4.dp, - dayHeaderTopPad = 10.dp, + dayGap = 8.dp, + monthLabel = 12.sp, ) private val MEDIUM_METRICS = AgendaMetrics( - title = 18.sp, // † - dayHeader = 14.sp, // M3 Title Small + pillWeekday = 12.sp, + pillDay = 18.sp, eventTitle = 16.sp, // M3 Body Large eventTime = 14.sp, // M3 Body Medium placeholder = 16.sp, // M3 Body Large message = 16.sp, // M3 Body Large - stripeH = 40.dp, - iconImage = 24.dp, - iconBox = 44.dp, + contentH = 40.dp, + pillWidth = 44.dp, rowVPad = 5.dp, - dayHeaderTopPad = 11.dp, + dayGap = 9.dp, + monthLabel = 14.sp, ) private val LARGE_METRICS = AgendaMetrics( - title = 20.sp, // † - dayHeader = 16.sp, // M3 Title Medium + pillWeekday = 12.sp, + pillDay = 20.sp, eventTitle = 18.sp, // † eventTime = 14.sp, // M3 Body Medium — the secondary line steps more slowly placeholder = 18.sp, // † on purpose, so the title keeps its message = 18.sp, // † lead and the hierarchy survives. - stripeH = 46.dp, - iconImage = 26.dp, - iconBox = 48.dp, + contentH = 46.dp, + pillWidth = 48.dp, rowVPad = 6.dp, - dayHeaderTopPad = 12.dp, + dayGap = 10.dp, + monthLabel = 14.sp, ) private val EXTRA_LARGE_METRICS = AgendaMetrics( - title = 22.sp, // M3 Title Large - dayHeader = 18.sp, // † + pillWeekday = 14.sp, + pillDay = 22.sp, eventTitle = 20.sp, // † eventTime = 16.sp, // M3 Body Large placeholder = 20.sp, // † message = 20.sp, // † - stripeH = 52.dp, - iconImage = 28.dp, - iconBox = 52.dp, + contentH = 52.dp, + pillWidth = 52.dp, rowVPad = 8.dp, - dayHeaderTopPad = 14.dp, + dayGap = 12.dp, + monthLabel = 16.sp, ) /** Indexed by [WidgetSize.ordinal] so lookup allocates nothing per recomposition. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt index 548572c..7afaf48 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt @@ -1,40 +1,27 @@ package de.jeanlucmakiola.calendula.widget.agenda +import de.jeanlucmakiola.calendula.widget.WidgetKind +import de.jeanlucmakiola.calendula.widget.previewSizeOf +import de.jeanlucmakiola.calendula.widget.provideSamplePreview import de.jeanlucmakiola.calendula.domain.isDeclined import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration import android.content.Context -import android.content.res.Configuration 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.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.GlanceModifier import androidx.glance.GlanceTheme -import androidx.glance.Image import androidx.glance.ImageProvider -import androidx.glance.action.ActionParameters import androidx.glance.action.clickable import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.SizeMode -import androidx.glance.appwidget.action.ActionCallback -import androidx.glance.appwidget.action.actionRunCallback import androidx.glance.appwidget.action.actionStartActivity import androidx.glance.appwidget.cornerRadius import androidx.glance.appwidget.lazy.LazyColumn import androidx.glance.appwidget.lazy.items import androidx.glance.appwidget.provideContent import androidx.glance.background -import androidx.glance.currentState -import androidx.glance.appwidget.state.getAppWidgetState import androidx.glance.state.PreferencesGlanceStateDefinition import androidx.glance.layout.Alignment import androidx.glance.layout.Box @@ -47,78 +34,58 @@ import androidx.glance.layout.height import androidx.glance.layout.padding import androidx.glance.layout.size import androidx.glance.layout.width +import androidx.glance.semantics.contentDescription +import androidx.glance.semantics.semantics +import androidx.glance.text.TextAlign +import androidx.glance.unit.ColorProvider +import de.jeanlucmakiola.floret.glance.WidgetHeader +import de.jeanlucmakiola.floret.glance.WidgetHeaderAction import androidx.glance.text.FontWeight import androidx.glance.text.Text import androidx.glance.text.TextStyle import de.jeanlucmakiola.calendula.MainActivity import de.jeanlucmakiola.calendula.R 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.hasEnded -import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange -import de.jeanlucmakiola.calendula.ui.agenda.AgendaTimeLabel -import de.jeanlucmakiola.calendula.ui.agenda.agendaTimeLabel import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing -import de.jeanlucmakiola.calendula.ui.agenda.dayCount -import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha -import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay +import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle import de.jeanlucmakiola.floret.locale.localizedDateFormatter -import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.eventFill +import de.jeanlucmakiola.calendula.ui.common.eventInk +import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA import de.jeanlucmakiola.calendula.widget.AgendaWidgetData -import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme import de.jeanlucmakiola.calendula.widget.WidgetSize -import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData +import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot +import de.jeanlucmakiola.calendula.widget.WidgetAppearance +import de.jeanlucmakiola.calendula.widget.widgetCardColor +import de.jeanlucmakiola.calendula.widget.widgetTimeLabel +import de.jeanlucmakiola.calendula.widget.loadAgendaWidget +import de.jeanlucmakiola.calendula.widget.widgetAppearance +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.positionOf +import de.jeanlucmakiola.floret.glance.WidgetGroupedRow +import de.jeanlucmakiola.floret.glance.themedColor +import de.jeanlucmakiola.floret.glance.wallpaperBacking +import de.jeanlucmakiola.floret.prefs.ThemeMode +import androidx.datastore.preferences.core.Preferences +import androidx.glance.currentState +import de.jeanlucmakiola.floret.glance.WidgetData +import de.jeanlucmakiola.floret.glance.currentRefreshStamp +import de.jeanlucmakiola.floret.glance.refreshStamp import de.jeanlucmakiola.calendula.widget.systemZone import de.jeanlucmakiola.calendula.widget.today import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate -import kotlinx.datetime.TimeZone import kotlinx.datetime.plus -import kotlinx.datetime.toLocalDateTime -import kotlin.time.Instant +import kotlinx.datetime.toJavaDayOfWeek 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 - * of events grouped under day headers (the Google "Schedule" widget model). + * of events, each day one card of grouped rows with its date pill beside it. * Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda. */ class AgendaWidget : GlanceAppWidget() { @@ -131,32 +98,47 @@ class AgendaWidget : GlanceAppWidget() { // the RemoteViews past the binder transaction limit. override val sizeMode = SizeMode.Single + override val previewSizeMode = previewSizeOf(WidgetKind.AGENDA) + + override suspend fun providePreview(context: Context, widgetCategory: Int) = + provideSamplePreview(context, WidgetKind.AGENDA) + override suspend fun provideGlance(context: Context, id: GlanceId) { - val stored = getAppWidgetState(context, id)[AGENDA_RANGE_KEY] - val data = context.loadAgendaWidgetData(stored?.let { parseAgendaRange(it, AgendaRange.Month) }) - val dark = (context.resources.configuration.uiMode and - Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + val stamp = refreshStamp(context, id) + val widgetData = WidgetData(context.loadAgendaWidget(id), stamp) provideContent { - CalendulaGlanceTheme { - AgendaWidgetBody(data = data, dark = dark) - } + // A running session never re-runs this preamble, so every redraw + // moves the stamp and the data reloads here instead (#420). + val loaded = widgetData.current(currentRefreshStamp()) { context.loadAgendaWidget(id) } + AgendaWidgetContent( + data = loaded.content, + appearance = currentState().widgetAppearance, + appTheme = loaded.appTheme, + ) } } } /** - * Redraws the widget (header refresh button). Targets the tapped widget's own id - * rather than `updateAll`, whose provider-name lookup is empty in a process a tap - * woke from cold — see `ShiftMonthAction` (#18). A cold process re-reads the - * calendar in the `provideGlance` preamble; a live session only recomposes from - * the snapshot it already has (see [AGENDA_RANGE_KEY]). + * The whole agenda widget, shared with its config screen's preview (#292). The + * [preview] draws its rows as a plain column, which RemoteViews can show + * outside a widget host on every Android version. */ -class RefreshAgendaAction : ActionCallback { - override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { - AgendaWidget().update(context.applicationContext, glanceId) +@Composable +internal fun AgendaWidgetContent( + 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 * stays well inside the binder transaction limit regardless of range and calendar @@ -166,57 +148,32 @@ class RefreshAgendaAction : ActionCallback { */ private const val MAX_AGENDA_ROWS = 100 -/** 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 { - data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow - data class Event(val date: LocalDate, val event: EventInstance) : AgendaRow - /** "Nothing left today" line under an anchored, event-less today (#35). */ - data class Placeholder(val date: LocalDate) : AgendaRow + val date: LocalDate + + data class Event(override val date: LocalDate, val event: EventInstance) : AgendaRow + /** "Nothing left today" line for an anchored, event-less today (#35). */ + data class Placeholder(override val date: LocalDate) : AgendaRow } @Composable -private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { - // Read reactively from per-instance Glance state, falling back to the saved - // pref for a freshly placed widget (#103, #51). - // The stripe is then re-resolved against the system font scale so it tracks the +private fun AgendaWidgetBody(data: AgendaWidgetData, preview: Boolean) { + // The row height is re-resolved against the system font scale so it tracks the // sp-sized text beside it instead of drifting at large accessibility settings. - val savedSize = (data as? AgendaWidgetData.Ready)?.savedWidgetSize ?: WidgetSize.SMALL - val size = currentState(AGENDA_SIZE_KEY) - ?.let { stored -> WidgetSize.entries.firstOrNull { it.name == stored } } - ?: savedSize + val size = (data as? AgendaWidgetData.Ready)?.textSize ?: WidgetSize.SMALL val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale val metrics = metricsFor(size).scaledForFont(fontScale) Column( modifier = GlanceModifier .fillMaxSize() - .background(GlanceTheme.colors.surface) .padding(horizontal = 8.dp, vertical = 6.dp), ) { - AgendaHeader(metrics) - Spacer(GlanceModifier.height(4.dp)) + AgendaHeader() + Spacer(GlanceModifier.height(6.dp)) when (data) { AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics) - is AgendaWidgetData.Ready -> { - // 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(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) - } + is AgendaWidgetData.Ready -> AgendaRows(data = data, metrics = metrics, preview = preview) } } } @@ -224,24 +181,16 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { @Composable private fun AgendaRows( data: AgendaWidgetData.Ready, - range: AgendaRange, - dark: Boolean, metrics: AgendaMetrics, + preview: Boolean, ) { - val pastDisplay = - parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay) - val showToday = currentState(AGENDA_SHOW_TODAY_STATE_KEY) ?: data.savedShowToday - val rangeEnd = data.today.plus( - range.dayCount(data.today, data.weekStart) - 1, - 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 pastDisplay = data.pastDisplay + // Drop finished events (and any day they leave empty) when the mode is + // Hide — so "Upcoming" really is. Then 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( days = data.days - .filter { it.date <= rangeEnd } .let { days -> if (pastDisplay == PastEventDisplay.HIDE) { days.mapNotNull { day -> @@ -253,183 +202,223 @@ private fun AgendaRows( } }, today = data.today, - enabled = showToday, + enabled = data.showToday, ) if (visibleDays.isEmpty()) { WidgetMessage(R.string.agenda_empty_title, metrics) } else { val rows = buildList { visibleDays.forEach { day -> - add(AgendaRow.Header(day.date, data.today)) if (day.events.isEmpty()) { add(AgendaRow.Placeholder(day.date)) } else { day.events.forEach { add(AgendaRow.Event(day.date, it)) } } } - } - // Bound the payload, then drop a day header the cut left - // stranded with nothing under it. - .take(MAX_AGENDA_ROWS) - .dropLastWhile { it is AgendaRow.Header } - LazyColumn(modifier = GlanceModifier.fillMaxSize()) { - items(rows.size) { index -> - when (val row = rows[index]) { - is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics) - is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics) - is AgendaRow.Event -> EventRow( - event = row.event, - day = row.date, - dark = dark, - soften = data.soften, - is24Hour = data.is24Hour, - dimmed = pastDisplay == PastEventDisplay.DIM && - row.event.hasEnded(data.now), - metrics = metrics, - ) + }.take(MAX_AGENDA_ROWS) + // Each day is one card of grouped rows (#71), with its date pill + // beside the first row. + val positions = dayPositions(rows) + val cardColor = widgetCardColor() + val item: @Composable (Int) -> Unit = { index -> + val row = rows[index] + val position = positions[index] + val first = position == Position.Top || position == Position.Alone + val dayRow: @Composable (GlanceModifier) -> Unit = { modifier -> + Row(modifier = modifier.fillMaxWidth()) { + Box(modifier = GlanceModifier.width(metrics.pillWidth)) { + if (first) DatePill(row.date, data.today, cardColor, metrics) + } + Spacer(GlanceModifier.width(GUTTER_GAP)) + Box(modifier = GlanceModifier.defaultWeight()) { + val rowColor = when (row) { + is AgendaRow.Event -> row.fill(data).let { themedColor(day = it.day, night = it.night) } + is AgendaRow.Placeholder -> cardColor + } + WidgetGroupedRow(position = position, color = rowColor) { + AgendaRowContent(row, data, metrics) + } + } } } + val gap = GlanceModifier.padding(top = if (first && index > 0) metrics.dayGap else 0.dp) + // The pills carry no month, so a new month gets its name above its first day. + if (first && index > 0 && row.date.month != rows[index - 1].date.month) { + Column(modifier = gap.fillMaxWidth()) { + MonthLabel(row.date, data.today, metrics) + dayRow(GlanceModifier) + } + } else { + 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): List = buildList { + var start = 0 + for (end in 1..rows.size) { + if (end == rows.size || rows[end].date != rows[start].date) { + for (i in start until end) add(positionOf(i - start, end - start)) + start = end } } } @Composable -private fun AgendaHeader(metrics: AgendaMetrics) { - val context = androidx.glance.LocalContext.current - Row( - modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - // Tap the "Upcoming" title to open the app on the user's default view - // (issue #20) — a null target resolves to the default home in the host. - Text( - text = context.getString(R.string.widget_agenda_title), - style = TextStyle( - color = GlanceTheme.colors.primary, - fontSize = metrics.title, - fontWeight = FontWeight.Medium, - ), - modifier = GlanceModifier - .defaultWeight() - .clickable( - actionStartActivity(MainActivity.openViewIntent(context, view = null)), - ), - ) - IconButton( - resId = R.drawable.ic_widget_refresh, - contentDescription = context.getString(R.string.widget_refresh), - onClick = GlanceModifier.clickable(actionRunCallback()), +private fun AgendaRowContent(row: AgendaRow, data: AgendaWidgetData.Ready, metrics: AgendaMetrics) { + when (row) { + is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics) + is AgendaRow.Event -> EventRow( + event = row.event, + day = row.date, + fill = row.fill(data), + is24Hour = data.is24Hour, 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), - onClick = GlanceModifier.clickable( - actionStartActivity( - MainActivity.openCreateIntent(context, today(systemZone())), - ), - ), - metrics = metrics, + action = actionStartActivity(MainActivity.openCreateIntent(context, today(systemZone()))), ) } } +/** The month name ("November", with its year outside the current one) above a new month's first day. */ @Composable -private fun IconButton( - resId: Int, - contentDescription: String, - onClick: GlanceModifier, - metrics: AgendaMetrics, -) { - Box( - modifier = GlanceModifier.size(metrics.iconBox).then(onClick), - contentAlignment = Alignment.Center, - ) { - Image( - provider = ImageProvider(resId), - contentDescription = contentDescription, - colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant), - modifier = GlanceModifier.size(metrics.iconImage), - ) - } -} - -@Composable -private fun DayHeaderRow(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) { - val context = androidx.glance.LocalContext.current +private fun MonthLabel(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) { Text( - text = agendaDayLabel(context, date, today), + text = monthName(date, today), + maxLines = 1, style = TextStyle( - color = if (date == today) GlanceTheme.colors.primary - else GlanceTheme.colors.onSurfaceVariant, - fontSize = metrics.dayHeader, + color = GlanceTheme.colors.onSurfaceVariant, + fontSize = metrics.monthLabel, fontWeight = FontWeight.Medium, ), modifier = GlanceModifier - .fillMaxWidth() - .padding( - start = 8.dp, - end = 8.dp, - top = metrics.dayHeaderTopPad, - bottom = metrics.rowVPad, - ) - .clickable( - actionStartActivity( - MainActivity.openDateIntent(context, date, CalendarView.Agenda), - ), - ), + .padding(bottom = 4.dp) + .wallpaperBacking(radius = 8.dp) + .padding(horizontal = 6.dp, vertical = 2.dp), ) } -/** - * "No more events today" under an anchored, event-less today (#35). Kept plain — - * a muted line indented to the event titles — since the widget's event rows are - * plain too (a stripe + text, not cards), so a card here would look out of place. - */ +/** A day's weekday and number in a rounded pill, filled for today; opens that day in the app. */ +@Composable +private fun DatePill(date: LocalDate, today: LocalDate, cardColor: ColorProvider, metrics: AgendaMetrics) { + val context = androidx.glance.LocalContext.current + val isToday = date == today + val container = if (isToday) GlanceTheme.colors.primary else cardColor + val content = if (isToday) GlanceTheme.colors.onPrimary else GlanceTheme.colors.onSurface + val label = agendaDayLabel(context, date, today) + Column( + modifier = GlanceModifier + .width(metrics.pillWidth) + .height(metrics.rowHeight) + .cornerRadius(metrics.pillWidth / 2) + .background(container) + .clickable(actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Agenda))) + .semantics { contentDescription = label }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = weekdayShort(date), + maxLines = 1, + style = TextStyle(color = content, fontSize = metrics.pillWeekday, textAlign = TextAlign.Center), + ) + Text( + text = date.day.toString(), + maxLines = 1, + style = TextStyle( + color = content, + fontSize = metrics.pillDay, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + ), + ) + } +} + +/** "No more events today" in an anchored, event-less today's card (#35), as tall as the pill beside it. */ @Composable private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) { val context = androidx.glance.LocalContext.current - Text( - text = context.getString(R.string.agenda_no_more_today), - style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder), + Box( modifier = GlanceModifier .fillMaxWidth() - .padding( - start = TEXT_INDENT, - end = 8.dp, - top = 2.dp, - bottom = metrics.rowVPad + 2.dp, - ) + .height(metrics.rowHeight) + .padding(horizontal = ROW_H_PAD) .clickable( actionStartActivity( MainActivity.openDateIntent(context, date, CalendarView.Agenda), ), ), - ) + contentAlignment = Alignment.CenterStart, + ) { + Text( + text = context.getString(R.string.agenda_no_more_today), + maxLines = 1, + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder), + ) + } +} + +/** An event row's light and dark fill: its calendar's container tone, faded when the row is dimmed. */ +private data class EventFill(val day: Color, val night: Color) + +private fun AgendaRow.Event.fill(data: AgendaWidgetData.Ready): EventFill { + val dimmed = data.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now) + fun tone(dark: Boolean) = eventFill(event.color, dark, data.soften).let { + if (dimmed) it.copy(alpha = EventDimAlpha) else it + } + return EventFill(day = tone(dark = false), night = tone(dark = true)) } @Composable private fun EventRow( event: EventInstance, day: LocalDate, - dark: Boolean, - soften: Boolean, + fill: EventFill, is24Hour: Boolean, - dimmed: Boolean, metrics: AgendaMetrics, ) { val context = androidx.glance.LocalContext.current val title = event.title.ifBlank { context.getString(R.string.event_untitled) } - // Glance has no generic alpha modifier, so dim by fading the colour stripe and - // dropping both text lines to the lower-emphasis on-surface-variant tone. - val stripeColor = eventAccent(event.color, dark, soften).let { - if (dimmed) it.copy(alpha = EventDimAlpha) else it - } - val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface - Row( + // Ink is picked against the opaque fill, then carries the dim along with it. + fun ink(fill: Color, alpha: Float) = eventInk(fill.copy(alpha = 1f), alpha * fill.alpha) + val titleInk = themedColor(day = ink(fill.day, 1f), night = ink(fill.night, 1f)) + val timeInk = themedColor( + day = ink(fill.day, SECONDARY_INK_ALPHA), + night = ink(fill.night, SECONDARY_INK_ALPHA), + ) + Column( modifier = GlanceModifier .fillMaxWidth() - .padding(horizontal = ROW_H_PAD, vertical = metrics.rowVPad) + .height(metrics.rowHeight) + .padding(horizontal = ROW_H_PAD) .clickable( actionStartActivity( MainActivity.openEventIntent( @@ -443,30 +432,21 @@ private fun EventRow( ), verticalAlignment = Alignment.CenterVertically, ) { - Box( - modifier = GlanceModifier - .width(STRIPE_WIDTH) - .height(metrics.stripeH) - .cornerRadius(3.dp) - .background(stripeColor), - ) {} - Spacer(GlanceModifier.width(STRIPE_GAP)) - Column(modifier = GlanceModifier.defaultWeight()) { - Text( - text = title, - maxLines = 1, - style = TextStyle( - color = titleColor, - fontSize = metrics.eventTitle, - textDecoration = glanceDeclinedDecoration(event.isDeclined), - ), - ) - Text( - text = eventTimeSummary(context, event, day, is24Hour), - maxLines = 1, - style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.eventTime), - ) - } + Text( + text = title, + maxLines = 1, + style = TextStyle( + color = titleInk, + fontSize = metrics.eventTitle, + fontWeight = FontWeight.Medium, + textDecoration = glanceDeclinedDecoration(event.isDeclined), + ), + ) + Text( + text = eventTimeSummary(context, event, day, is24Hour), + maxLines = 1, + style = TextStyle(color = timeInk, fontSize = metrics.eventTime), + ) } } @@ -480,12 +460,11 @@ private fun WidgetMessage(resId: Int, metrics: AgendaMetrics) { Text( text = context.getString(resId), style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.message), + modifier = GlanceModifier.wallpaperBacking().padding(horizontal = 12.dp, vertical = 8.dp), ) } } -private fun zone(): TimeZone = systemZone() - /** "Today · Wed, 17 Jun" — relative word for today/tomorrow, else the date. */ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate): String { val relative = when (date) { @@ -501,26 +480,23 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate): return if (relative != null) "$relative · $formatted" else formatted } +private fun monthName(date: LocalDate, today: LocalDate): String = formatCalendarTitle( + date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, 1), + locale = Locale.getDefault(), + currentYear = today.year, + skeleton = "LLLL", +) + +private fun weekdayShort(date: LocalDate): String = + date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, Locale.getDefault()) + private fun eventTimeSummary( context: Context, event: EventInstance, day: LocalDate, is24Hour: Boolean, ): String { - val time = when (val label = agendaTimeLabel(event, day, zone())) { - AgendaTimeLabel.AllDay -> context.getString(R.string.event_detail_all_day) - is AgendaTimeLabel.Starts -> - context.getString(R.string.agenda_span_starts, formatTime(label.start, is24Hour)) - is AgendaTimeLabel.Ends -> - context.getString(R.string.agenda_span_ends, formatTime(label.end, is24Hour)) - is AgendaTimeLabel.Range -> - "${formatTime(label.start, is24Hour)} – ${formatTime(label.end, is24Hour)}" - } + val time = widgetTimeLabel(context, event, day, is24Hour) val location = event.location?.takeIf { it.isNotBlank() } return if (location != null) "$time · $location" else time } - -private fun formatTime(instant: Instant, is24Hour: Boolean): String { - val t = instant.toLocalDateTime(zone()).time - return formatTimeOfDay(t.hour, t.minute, is24Hour, Locale.getDefault()) -} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt index 736da00..01eab36 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidgetReceiver.kt @@ -1,44 +1,9 @@ package de.jeanlucmakiola.calendula.widget.agenda -import android.appwidget.AppWidgetManager -import android.content.Context import androidx.glance.appwidget.GlanceAppWidget -import androidx.glance.appwidget.GlanceAppWidgetReceiver -import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler +import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver -/** - * Host-facing receiver for the agenda widget. Declared in the manifest with the - * `appwidget_info_agenda` provider metadata; delegates all rendering to - * [AgendaWidget]. - */ -class AgendaWidgetReceiver : GlanceAppWidgetReceiver() { +/** Host-facing receiver for the agenda widget; rendering is [AgendaWidget]'s. */ +class AgendaWidgetReceiver : CalendulaWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = AgendaWidget() - - /** First agenda widget placed — start rolling "today" over at midnight (#228). */ - override fun onEnabled(context: Context) { - super.onEnabled(context) - WidgetRolloverScheduler.sync(context) - } - - /** - * Last agenda widget removed. [WidgetRolloverScheduler.sync] cancels only if - * no month widget is left either. - */ - override fun onDisabled(context: Context) { - super.onDisabled(context) - WidgetRolloverScheduler.sync(context) - } - - /** - * Self-heal on the system's own `updatePeriodMillis` wake-up — see - * [de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver.onUpdate]. - */ - override fun onUpdate( - context: Context, - appWidgetManager: AppWidgetManager, - appWidgetIds: IntArray, - ) { - super.onUpdate(context, appWidgetManager, appWidgetIds) - WidgetRolloverScheduler.sync(context) - } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/CountdownOptionsSection.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/CountdownOptionsSection.kt new file mode 100644 index 0000000..1866d0d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/CountdownOptionsSection.kt @@ -0,0 +1,178 @@ +package de.jeanlucmakiola.calendula.widget.config + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip +import de.jeanlucmakiola.calendula.ui.common.PickerSearchField +import de.jeanlucmakiola.calendula.widget.countdown.CountdownMode +import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedChoiceRow +import de.jeanlucmakiola.floret.components.GroupedHint +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.GroupedSectionHeader +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.positionOf +import de.jeanlucmakiola.floret.locale.currentLocale +import de.jeanlucmakiola.floret.locale.localizedDateFormatter +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toJavaLocalDate +import kotlinx.datetime.toLocalDateTime + +/** What the countdown widget counts to (#439): the next event, or one picked from a searchable list. */ +@Composable +internal fun CountdownOptionsSection( + options: CountdownOptions, + candidates: CountdownCandidates, + onChange: (CountdownOptions) -> Unit, + onLoadCandidates: () -> Unit, +) { + var picking by rememberSaveable { mutableStateOf(false) } + GroupedSectionHeader(stringResource(R.string.widget_config_countdown)) + GroupedChoiceRow( + title = stringResource(R.string.widget_config_countdown), + options = CountdownMode.entries, + selected = options.mode, + label = { + stringResource( + when (it) { + CountdownMode.NEXT -> R.string.widget_config_countdown_next + CountdownMode.PINNED -> R.string.widget_config_countdown_pinned + }, + ) + }, + onSelect = { mode -> + onChange(options.copy(mode = mode)) + if (mode == CountdownMode.PINNED && options.eventId == null) picking = true + }, + position = if (options.mode == CountdownMode.PINNED) Position.Top else Position.Alone, + ) + when (options.mode) { + CountdownMode.NEXT -> GroupedHint(stringResource(R.string.widget_config_countdown_next_hint), verticalPadding = 8.dp) + CountdownMode.PINNED -> GroupedRow( + title = stringResource(R.string.widget_config_countdown_event), + summary = options.title ?: stringResource(R.string.widget_config_countdown_event_none), + position = Position.Bottom, + onClick = { picking = true }, + ) + } + if (picking) { + LaunchedEffect(Unit) { onLoadCandidates() } + CountdownEventPicker( + candidates = candidates, + selectedEventId = options.eventId, + onRetry = onLoadCandidates, + onSelect = { candidate -> + onChange(options.copy(mode = CountdownMode.PINNED, eventId = candidate.event.eventId, title = candidate.event.title)) + picking = false + }, + onDismiss = { picking = false }, + ) + } +} + +@Composable +private fun CountdownEventPicker( + candidates: CountdownCandidates, + selectedEventId: Long?, + onRetry: () -> Unit, + onSelect: (CountdownCandidate) -> Unit, + onDismiss: () -> Unit, +) { + var query by rememberSaveable { mutableStateOf("") } + FullScreenPicker( + title = stringResource(R.string.widget_config_countdown_event), + onDismiss = onDismiss, + predictiveBack = true, + scrollable = false, + ) { + PickerSearchField( + query = query, + onQueryChange = { query = it }, + placeholder = stringResource(R.string.widget_config_countdown_search), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + when (candidates) { + CountdownCandidates.Idle, CountdownCandidates.Loading -> Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator() } + CountdownCandidates.Failed -> Column( + modifier = Modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + PickerMessage(stringResource(R.string.widget_config_countdown_load_failed)) + FilledTonalButton(onClick = onRetry, modifier = Modifier.padding(top = 16.dp)) { + Text(stringResource(R.string.state_retry)) + } + } + is CountdownCandidates.Ready -> { + val locale = currentLocale() + val matches = remember(candidates, query) { + val needle = query.trim() + if (needle.isEmpty()) candidates.events + else candidates.events.filter { it.event.title.contains(needle, ignoreCase = true) } + } + if (matches.isEmpty()) { + PickerMessage( + stringResource(R.string.widget_config_countdown_empty), + Modifier.fillMaxWidth().padding(24.dp), + ) + } else { + val zone = remember { TimeZone.currentSystemDefault() } + val formatter = remember(locale) { localizedDateFormatter(locale, "EEEdMMMy") } + LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) { + itemsIndexed(matches, key = { _, it -> it.event.eventId }) { index, candidate -> + val date = formatter.format(candidate.event.start.toLocalDateTime(zone).date.toJavaLocalDate()) + GroupedRow( + title = candidate.event.title.ifBlank { stringResource(R.string.event_untitled) }, + summary = if (candidate.repeats) stringResource(R.string.widget_config_countdown_repeats, date) else date, + position = positionOf(index, matches.size), + selected = candidate.event.eventId == selectedEventId, + minHeight = 56.dp, + leading = { CalendarColorChip(candidate.event.color) }, + onClick = { onSelect(candidate) }, + ) + } + } + } + } + } + } +} + +@Composable +private fun PickerMessage(text: String, modifier: Modifier = Modifier) { + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = modifier, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/PlacedWidgets.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/PlacedWidgets.kt new file mode 100644 index 0000000..458e060 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/PlacedWidgets.kt @@ -0,0 +1,32 @@ +package de.jeanlucmakiola.calendula.widget.config + +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import androidx.glance.appwidget.GlanceAppWidgetManager +import de.jeanlucmakiola.calendula.widget.WidgetAppearance +import de.jeanlucmakiola.calendula.widget.WidgetKind +import de.jeanlucmakiola.calendula.widget.widgetAppearance +import de.jeanlucmakiola.calendula.widget.widgetState + +/** One of the app's widgets on the home screen, for the settings list. */ +data class PlacedWidget(val appWidgetId: Int, val kind: WidgetKind, val appearance: WidgetAppearance) + +/** + * Every placed widget, agenda ones first, each in placement order. An id the + * host bound but never laid out (a placement it abandoned without freeing the + * id) has no size yet and is left out, as is one removed while this reads. + */ +suspend fun Context.placedWidgets(): List { + 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) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetCalendarPicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetCalendarPicker.kt new file mode 100644 index 0000000..a9122e6 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetCalendarPicker.kt @@ -0,0 +1,60 @@ +package de.jeanlucmakiola.calendula.widget.config + +import androidx.compose.material3.Checkbox +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip +import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle +import de.jeanlucmakiola.calendula.ui.common.groupByAccount +import de.jeanlucmakiola.calendula.ui.common.orUnnamed +import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedHint +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.GroupedSectionHeader +import de.jeanlucmakiola.floret.components.positionOf + +/** Which of the app's calendars one widget shows, grouped by account like the drawer's filter. */ +@Composable +internal fun WidgetCalendarPicker( + calendars: List, + hidden: Set, + onSetShown: (Long, Boolean) -> Unit, + onDismiss: () -> Unit, +) { + FullScreenPicker( + title = stringResource(R.string.widget_config_calendars), + onDismiss = onDismiss, + predictiveBack = true, + ) { + GroupedHint(stringResource(R.string.widget_config_calendars_hint), verticalPadding = 8.dp) + calendars.groupByAccount().forEach { group -> + GroupedSectionHeader(accountGroupTitle(group)) + group.calendars.forEachIndexed { index, calendar -> + val shown = calendar.id !in hidden + GroupedRow( + title = calendar.displayName.orUnnamed(), + position = positionOf(index, group.calendars.size), + minHeight = 56.dp, + leading = { CalendarColorChip(calendar.color) }, + trailing = { Checkbox(checked = shown, onCheckedChange = { onSetShown(calendar.id, it) }) }, + onClick = { onSetShown(calendar.id, !shown) }, + ) + } + } + } +} + +/** "All calendars", or how many of them the widget shows. */ +@Composable +internal fun calendarsSummary(calendars: List, hidden: Set): String { + val shown = calendars.count { it.id !in hidden } + return if (shown == calendars.size) { + stringResource(R.string.widget_config_calendars_all) + } else { + pluralStringResource(R.plurals.widget_config_calendars_some, calendars.size, shown, calendars.size) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigActivity.kt new file mode 100644 index 0000000..2296bbf --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigActivity.kt @@ -0,0 +1,133 @@ +package de.jeanlucmakiola.calendula.widget.config + +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import dagger.hilt.android.AndroidEntryPoint +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.calendula.domain.FontRole +import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings +import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme +import de.jeanlucmakiola.calendula.ui.theme.WindowThemeCache +import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography +import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily +import de.jeanlucmakiola.floret.prefs.ThemeMode +import de.jeanlucmakiola.floret.prefs.get +import de.jeanlucmakiola.floret.prefs.isDark +import de.jeanlucmakiola.floret.prefs.isPureBlack +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * A widget's config screen (#292): opened by the launcher when the widget is + * placed, from its "Edit widget" action on Android 12+, and from the app's + * widget settings. Shows over the wallpaper, so a transparent widget previews + * against the real home screen. + */ +@AndroidEntryPoint +class WidgetConfigActivity : ComponentActivity() { + + @Inject lateinit var settingsPrefs: SettingsPrefs + + private val viewModel: WidgetConfigViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val appWidgetId = viewModel.appWidgetId + // Backing out of a fresh placement leaves no unconfigured widget behind. + setResult(RESULT_CANCELED, result(appWidgetId)) + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish() + return + } + enableEdgeToEdge() + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.events.collect { event -> + when (event) { + WidgetConfigEvent.SAVED -> { + setResult(RESULT_OK, result(appWidgetId)) + finish() + } + WidgetConfigEvent.SAVE_FAILED -> + Toast.makeText(this@WidgetConfigActivity, R.string.widget_config_save_failed, Toast.LENGTH_SHORT).show() + } + } + } + } + val fontSettings = settingsPrefs.data.map { + AppFontSettings( + brand = it[SettingsKeys.brandFont], + plain = it[SettingsKeys.plainFont], + brandStamp = it[SettingsKeys.brandFontStamp], + plainStamp = it[SettingsKeys.plainFontStamp], + ) + } + // Seeded from MainActivity's mirrored copy, so an app theme override + // shows from the first frame. + val cachedTheme = WindowThemeCache.read(this) ?: ThemeMode.SYSTEM + val cachedDynamicColor = WindowThemeCache.readDynamicColor(this) ?: true + setContent { + val themeMode by settingsPrefs.themeMode.collectAsStateWithLifecycle(cachedTheme) + val dynamicColor by settingsPrefs.dynamicColor.collectAsStateWithLifecycle(cachedDynamicColor) + val fonts by fontSettings.collectAsStateWithLifecycle(initialValue = null) + val context = LocalContext.current + // Nothing until the font choice is known, rather than a frame in the default one. + val typography = remember(fonts, context) { + fonts?.let { + calendulaTypography( + brand = resolveFontFamily(it.brand, FontRole.BRAND, context), + plain = resolveFontFamily(it.plain, FontRole.PLAIN, context), + ) + } + } ?: return@setContent + CalendulaTheme( + darkTheme = themeMode.isDark(isSystemInDarkTheme()), + dynamicColor = dynamicColor, + pureBlack = themeMode.isPureBlack, + typography = typography, + ) { + val state by viewModel.state.collectAsStateWithLifecycle() + if (state is WidgetConfigUiState.Missing) LaunchedEffect(Unit) { finish() } + WidgetConfigScreen( + state = state, + onClose = ::finish, + onSave = viewModel::save, + onRetry = viewModel::retry, + onAppearance = viewModel::setAppearance, + onContent = viewModel::setContent, + onCalendarShown = viewModel::setCalendarShown, + onCountdown = viewModel::setCountdown, + onLoadCountdownCandidates = viewModel::loadCountdownCandidates, + ) + } + } + } + + private fun result(appWidgetId: Int) = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + + companion object { + /** Opens placed widget [appWidgetId]'s config screen from inside the app. */ + fun intent(context: Context, appWidgetId: Int): Intent = + Intent(context, WidgetConfigActivity::class.java) + .putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt new file mode 100644 index 0000000..f410e6e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigScreen.kt @@ -0,0 +1,396 @@ +package de.jeanlucmakiola.calendula.widget.config + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay +import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker +import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel +import de.jeanlucmakiola.calendula.ui.settings.pastEventDisplayLabelRes +import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions +import de.jeanlucmakiola.calendula.widget.WidgetAppearance +import de.jeanlucmakiola.calendula.widget.WidgetOption +import de.jeanlucmakiola.calendula.widget.WidgetSize +import de.jeanlucmakiola.calendula.widget.WidgetTheme +import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetContent +import de.jeanlucmakiola.calendula.widget.agenda.metricsFor +import de.jeanlucmakiola.calendula.widget.month.MonthWidgetContent +import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetContent +import de.jeanlucmakiola.calendula.widget.date.DateWidgetContent +import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetContent +import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetContent +import de.jeanlucmakiola.calendula.widget.today.TodayWidgetContent +import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions +import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetContent +import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetContent +import de.jeanlucmakiola.calendula.widget.WidgetKind +import de.jeanlucmakiola.floret.components.GroupedChoiceRow +import de.jeanlucmakiola.floret.components.GroupedHint +import de.jeanlucmakiola.floret.components.GroupedRow +import de.jeanlucmakiola.floret.components.GroupedSectionHeader +import de.jeanlucmakiola.floret.components.GroupedSwitchRow +import de.jeanlucmakiola.floret.components.OptionPicker +import de.jeanlucmakiola.floret.components.Position +import de.jeanlucmakiola.floret.components.positionOf +import de.jeanlucmakiola.floret.glance.GlanceWidgetPreview +import de.jeanlucmakiola.floret.glance.WallpaperWindow +import de.jeanlucmakiola.floret.glance.WidgetBackground +import kotlin.math.roundToInt + +private val PreviewMaxHeight = 280.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun WidgetConfigScreen( + state: WidgetConfigUiState, + onClose: () -> Unit, + onSave: () -> Unit, + onRetry: () -> Unit, + onAppearance: (WidgetAppearance) -> Unit, + onContent: (AgendaWidgetOptions) -> Unit, + onCalendarShown: (Long, Boolean) -> Unit, + onCountdown: (CountdownOptions) -> Unit, + onLoadCountdownCandidates: () -> Unit, +) { + val ready = state as? WidgetConfigUiState.Ready + val surface = MaterialTheme.colorScheme.surface + Column(modifier = Modifier.fillMaxSize()) { + TopAppBar( + title = { + Text( + text = ready?.let { stringResource(it.kind.label) }.orEmpty(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + IconButton(onClick = onClose) { + Icon(Icons.Default.Close, contentDescription = stringResource(R.string.event_edit_close)) + } + }, + actions = { + Button( + onClick = onSave, + enabled = ready != null && !ready.saving, + modifier = Modifier.padding(end = 8.dp), + ) { + Text(stringResource(R.string.dialog_save)) + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = surface), + ) + when (state) { + WidgetConfigUiState.Loading, WidgetConfigUiState.Missing -> Box( + modifier = Modifier.fillMaxSize().background(surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + WidgetConfigUiState.Failed -> Column( + modifier = Modifier.fillMaxSize().background(surface).padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.widget_config_load_failed), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(16.dp)) + FilledTonalButton(onClick = onRetry) { Text(stringResource(R.string.state_retry)) } + } + is WidgetConfigUiState.Ready -> { + WallpaperWindow(color = surface) { + val maxSize = DpSize(maxWidth, PreviewMaxHeight) + when (val preview = state.preview) { + null -> Box( + modifier = Modifier.padding(vertical = 16.dp).height(maxSize.height / 2), + contentAlignment = Alignment.Center, + ) { + if (state.previewFailed) PreviewFailed() else CircularProgressIndicator() + } + else -> GlanceWidgetPreview( + size = state.placedSize, + maxSize = maxSize, + preview, + state.appearance, + state.appTheme, + modifier = Modifier.padding(vertical = 16.dp), + placeholder = { CircularProgressIndicator() }, + failure = { PreviewFailed() }, + ) { + when (preview) { + is WidgetPreviewData.Agenda -> + AgendaWidgetContent(preview.data, state.appearance, state.appTheme, preview = true) + is WidgetPreviewData.Month -> + MonthWidgetContent(preview.source, preview.index, state.appearance, state.appTheme) + is WidgetPreviewData.MonthDay -> MonthDayWidgetContent( + preview.data, + preview.index, + selected = null, + appearance = state.appearance, + appTheme = state.appTheme, + preview = true, + ) + is WidgetPreviewData.Countdown -> + CountdownWidgetContent(preview.data, state.appearance, state.appTheme) + is WidgetPreviewData.Days -> when (state.kind) { + WidgetKind.TODAY -> + TodayWidgetContent(preview.data, state.appearance, state.appTheme, preview = true) + WidgetKind.UPCOMING -> UpcomingWidgetContent(preview.data, state.appearance, state.appTheme) + else -> WeekStripWidgetContent(preview.data, state.appearance, state.appTheme) + } + is WidgetPreviewData.Today -> if (state.kind == WidgetKind.DATE) { + DateWidgetContent(preview.today, state.appearance, state.appTheme) + } else { + QuickAddWidgetContent(preview.today, state.appearance, state.appTheme) + } + } + } + } + } + ConfigOptions( + state = state, + onAppearance = onAppearance, + onContent = onContent, + onCalendarShown = onCalendarShown, + onCountdown = onCountdown, + onLoadCountdownCandidates = onLoadCountdownCandidates, + modifier = Modifier + .weight(1f) + .background(surface), + ) + } + } + } +} + +@Composable +private fun PreviewFailed() { + Text( + text = stringResource(R.string.widget_config_preview_failed), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier + .background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.large) + .padding(horizontal = 16.dp, vertical = 8.dp), + ) +} + +@Composable +private fun ConfigOptions( + state: WidgetConfigUiState.Ready, + onAppearance: (WidgetAppearance) -> Unit, + onContent: (AgendaWidgetOptions) -> Unit, + onCalendarShown: (Long, Boolean) -> Unit, + onCountdown: (CountdownOptions) -> Unit, + onLoadCountdownCandidates: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .verticalScroll(rememberScrollState()) + .navigationBarsPadding() + .padding(bottom = 16.dp), + ) { + GroupedSectionHeader(stringResource(R.string.settings_section_appearance)) + GroupedChoiceRow( + title = stringResource(R.string.widget_config_background), + options = WidgetBackground.entries, + selected = state.appearance.background, + label = { backgroundLabel(it) }, + onSelect = { onAppearance(state.appearance.copy(background = it)) }, + position = Position.Top, + ) + GroupedChoiceRow( + title = stringResource(R.string.settings_theme), + options = WidgetTheme.entries, + selected = state.appearance.theme, + label = { themeLabel(it) }, + onSelect = { onAppearance(state.appearance.copy(theme = it)) }, + position = Position.Bottom, + ) + if (state.kind == WidgetKind.COUNTDOWN) { + CountdownOptionsSection( + options = state.draft.countdown, + candidates = state.candidates, + onChange = onCountdown, + onLoadCandidates = onLoadCountdownCandidates, + ) + } + if (state.kind.options.isNotEmpty()) { + ContentOptions(state = state, onChange = onContent, onCalendarShown = onCalendarShown) + } + } +} + +@Composable +private fun ContentOptions( + state: WidgetConfigUiState.Ready, + onChange: (AgendaWidgetOptions) -> Unit, + onCalendarShown: (Long, Boolean) -> Unit, +) { + val options = state.draft.content + var showRange by rememberSaveable { mutableStateOf(false) } + var showSize by rememberSaveable { mutableStateOf(false) } + var showPast by rememberSaveable { mutableStateOf(false) } + var showCalendars by rememberSaveable { mutableStateOf(false) } + + // Rows in WidgetOption order, each shaped by where it lands among the kind's own. + val rows = WidgetOption.entries.filter { it in state.kind.options } + fun position(option: WidgetOption) = positionOf(rows.indexOf(option), rows.size) + + GroupedSectionHeader(stringResource(R.string.widget_config_content)) + rows.forEach { option -> + when (option) { + WidgetOption.RANGE -> GroupedRow( + title = stringResource(R.string.widget_config_range), + summary = agendaRangeLabel(options.range), + position = position(option), + onClick = { showRange = true }, + ) + WidgetOption.TEXT_SIZE -> GroupedRow( + title = stringResource(R.string.widget_config_text_size), + summary = widgetSizeLabel(options.textSize), + position = position(option), + onClick = { showSize = true }, + ) + WidgetOption.PAST_DISPLAY -> GroupedRow( + title = stringResource(R.string.settings_past_events), + summary = stringResource(pastEventDisplayLabelRes(options.pastDisplay)), + position = position(option), + onClick = { showPast = true }, + ) + WidgetOption.SHOW_TODAY -> GroupedSwitchRow( + title = stringResource(R.string.settings_agenda_show_today), + checked = options.showToday, + onCheckedChange = { onChange(options.copy(showToday = it)) }, + position = position(option), + ) + WidgetOption.CALENDARS -> GroupedRow( + title = stringResource(R.string.widget_config_calendars), + summary = calendarsSummary(state.calendars, state.draft.hiddenCalendars), + position = position(option), + onClick = { showCalendars = true }, + ) + } + } + + if (showCalendars) { + WidgetCalendarPicker( + calendars = state.calendars, + hidden = state.draft.hiddenCalendars, + onSetShown = onCalendarShown, + onDismiss = { showCalendars = false }, + ) + } + + if (showRange) { + AgendaRangePicker( + title = stringResource(R.string.widget_config_range), + description = stringResource(R.string.settings_agenda_widget_range_hint), + selected = options.range, + weekStart = state.weekStart, + onSelect = { onChange(options.copy(range = it)) }, + onDismiss = { showRange = false }, + ) + } + if (showSize) { + OptionPicker( + title = stringResource(R.string.widget_config_text_size), + header = { GroupedHint(stringResource(R.string.settings_widget_size_hint), verticalPadding = 8.dp) }, + predictiveBack = true, + options = WidgetSize.entries, + selected = options.textSize, + label = { widgetSizeLabel(it) }, + summary = { widgetSizeSummary(it) }, + onSelect = { onChange(options.copy(textSize = it)) }, + onDismiss = { showSize = false }, + ) + } + if (showPast) { + OptionPicker( + title = stringResource(R.string.settings_past_events), + header = { GroupedHint(stringResource(R.string.settings_past_events_hint), verticalPadding = 8.dp) }, + predictiveBack = true, + options = PastEventDisplay.entries, + selected = options.pastDisplay, + label = { stringResource(pastEventDisplayLabelRes(it)) }, + onSelect = { onChange(options.copy(pastDisplay = it)) }, + onDismiss = { showPast = false }, + ) + } +} + +@Composable +internal fun backgroundLabel(background: WidgetBackground): String = stringResource( + when (background) { + WidgetBackground.SOLID -> R.string.widget_config_background_solid + WidgetBackground.TINTED -> R.string.widget_config_background_tinted + WidgetBackground.TRANSPARENT -> R.string.widget_config_background_transparent + }, +) + +@Composable +internal fun themeLabel(theme: WidgetTheme): String = stringResource( + when (theme) { + WidgetTheme.FOLLOW_APP -> R.string.widget_config_theme_follow_app + WidgetTheme.LIGHT -> R.string.settings_theme_light + WidgetTheme.DARK -> R.string.settings_theme_dark + }, +) + +/** + * What a size step does, as a percentage of the smallest one's event text. + * Derived from the widget's own [metricsFor] table rather than hardcoded. + */ +@Composable +private fun widgetSizeSummary(size: WidgetSize): String { + val base = metricsFor(WidgetSize.SMALL).eventTitle.value + val percent = (metricsFor(size).eventTitle.value / base * 100f).roundToInt() + return stringResource(R.string.settings_widget_size_summary, percent) +} + +@Composable +private fun widgetSizeLabel(size: WidgetSize): String = stringResource( + when (size) { + WidgetSize.SMALL -> R.string.settings_widget_size_small + WidgetSize.MEDIUM -> R.string.settings_widget_size_medium + WidgetSize.LARGE -> R.string.settings_widget_size_large + WidgetSize.EXTRA_LARGE -> R.string.settings_widget_size_extra_large + }, +) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigViewModel.kt new file mode 100644 index 0000000..5d44ea6 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigViewModel.kt @@ -0,0 +1,335 @@ +package de.jeanlucmakiola.calendula.widget.config + +import android.appwidget.AppWidgetManager +import android.content.Context +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository +import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.widget.WidgetKind +import de.jeanlucmakiola.calendula.widget.DaysWidgetData +import de.jeanlucmakiola.calendula.widget.WidgetLoadContext +import de.jeanlucmakiola.calendula.widget.WidgetSettings +import de.jeanlucmakiola.calendula.widget.loadDays +import de.jeanlucmakiola.calendula.widget.weekOf +import de.jeanlucmakiola.calendula.widget.upcoming.loadUpcoming +import de.jeanlucmakiola.calendula.widget.today.loadTodayList +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.widget.countdown.CountdownData +import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions +import de.jeanlucmakiola.calendula.widget.countdown.countdownOptions +import de.jeanlucmakiola.calendula.widget.countdown.loadCountdown +import de.jeanlucmakiola.calendula.widget.countdown.putCountdownOptions +import de.jeanlucmakiola.calendula.widget.split.MonthDayData +import de.jeanlucmakiola.calendula.widget.split.loadMonthDay +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import de.jeanlucmakiola.calendula.widget.WidgetOption +import de.jeanlucmakiola.calendula.widget.hiddenCalendars +import de.jeanlucmakiola.calendula.widget.putHiddenCalendars +import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay +import de.jeanlucmakiola.calendula.widget.AgendaWidgetData +import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions +import de.jeanlucmakiola.calendula.widget.MonthWidgetSource +import de.jeanlucmakiola.calendula.widget.WidgetAppearance +import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData +import de.jeanlucmakiola.calendula.widget.loadThemedMonthSource +import de.jeanlucmakiola.calendula.widget.monthIndexOf +import de.jeanlucmakiola.calendula.widget.monthWindowCenter +import de.jeanlucmakiola.calendula.widget.agendaWidgetOptions +import de.jeanlucmakiola.calendula.widget.putAgendaOptions +import de.jeanlucmakiola.calendula.widget.putAppearance +import de.jeanlucmakiola.calendula.widget.systemZone +import de.jeanlucmakiola.calendula.widget.today +import de.jeanlucmakiola.calendula.widget.widgetAppearance +import de.jeanlucmakiola.calendula.widget.widgetSettings +import de.jeanlucmakiola.calendula.widget.widgetState +import de.jeanlucmakiola.floret.glance.updateWithRefresh +import de.jeanlucmakiola.floret.prefs.ThemeMode +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import java.util.Locale + +/** What the preview draws, loaded with the draft's options. */ +sealed interface WidgetPreviewData { + data class Agenda(val data: AgendaWidgetData) : WidgetPreviewData + data class Month(val source: MonthWidgetSource, val index: Int) : WidgetPreviewData + data class Today(val today: LocalDate) : WidgetPreviewData + data class Days(val data: DaysWidgetData) : WidgetPreviewData + data class Countdown(val data: CountdownData) : WidgetPreviewData + data class MonthDay(val data: MonthDayData, val index: Int) : WidgetPreviewData +} + +/** The options being edited, applied to the preview as they change and written on save. */ +data class WidgetDraft( + val appearance: WidgetAppearance, + /** The content options; stored only for a kind that [WidgetKind.hasContentOptions]. */ + val content: AgendaWidgetOptions, + val hiddenCalendars: Set, + val countdown: CountdownOptions = CountdownOptions(), +) + +/** One event or series the countdown can be pinned to, at its next occurrence. */ +data class CountdownCandidate(val event: EventInstance, val repeats: Boolean) + +/** The countdown's event list, loaded when its picker first opens. */ +sealed interface CountdownCandidates { + data object Idle : CountdownCandidates + data object Loading : CountdownCandidates + data object Failed : CountdownCandidates + data class Ready(val events: List) : CountdownCandidates +} + +private fun WidgetDraft.loadContext(settings: WidgetSettings) = WidgetLoadContext(settings, content, hiddenCalendars) + +sealed interface WidgetConfigUiState { + data object Loading : WidgetConfigUiState + + /** The widget is gone, or isn't one of ours; the screen closes. */ + data object Missing : WidgetConfigUiState + + /** Its stored options couldn't be read. */ + data object Failed : WidgetConfigUiState + + data class Ready( + val kind: WidgetKind, + val draft: WidgetDraft, + /** The calendars the app shows, for the widget's own calendar choice. */ + val calendars: List, + val appTheme: ThemeMode, + val weekStart: DayOfWeek, + /** The widget's size on the home screen, which the preview is laid out at. */ + val placedSize: DpSize, + /** Null until the first preview load lands. */ + val preview: WidgetPreviewData?, + val candidates: CountdownCandidates = CountdownCandidates.Idle, + /** The first preview load failed, so there's nothing to show. */ + val previewFailed: Boolean = false, + val saving: Boolean = false, + ) : WidgetConfigUiState { + val appearance: WidgetAppearance get() = draft.appearance + } +} + +/** One-time outcomes of a save, for the screen that's showing when they land. */ +enum class WidgetConfigEvent { SAVED, SAVE_FAILED } + +/** + * Backs [WidgetConfigActivity] (#292): the placed widget's stored options as an + * editable draft, a preview loaded with that draft, and the save that writes it + * into the widget's Glance state and redraws it. + */ +@HiltViewModel +class WidgetConfigViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val prefs: SettingsPrefs, + private val calendarPrefs: CalendarPrefs, + private val repository: CalendarRepository, + savedState: SavedStateHandle, +) : ViewModel() { + + val appWidgetId: Int = savedState[AppWidgetManager.EXTRA_APPWIDGET_ID] ?: AppWidgetManager.INVALID_APPWIDGET_ID + + private val _state = MutableStateFlow(WidgetConfigUiState.Loading) + val state: StateFlow = _state.asStateFlow() + + private val _events = Channel(Channel.BUFFERED) + val events: Flow = _events.receiveAsFlow() + + private var previewJob: Job? = null + + init { + viewModelScope.launch { load() } + } + + fun retry() { + if (_state.value != WidgetConfigUiState.Failed) return + _state.value = WidgetConfigUiState.Loading + viewModelScope.launch { load() } + } + + private suspend fun load() { + val kind = WidgetKind.of(context, appWidgetId) + if (kind == null) { + _state.value = WidgetConfigUiState.Missing + return + } + _state.value = try { + val stored = context.widgetState(GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId)) + val settings = prefs.widgetSettings.first() + WidgetConfigUiState.Ready( + kind = kind, + draft = WidgetDraft( + appearance = stored.widgetAppearance, + content = stored.agendaWidgetOptions(settings.agendaDefaults), + hiddenCalendars = stored.hiddenCalendars, + countdown = stored.countdownOptions, + ), + calendars = if (WidgetOption.CALENDARS in kind.options) shownCalendars() else emptyList(), + appTheme = settings.themeMode, + weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()), + placedSize = placedSize(kind), + preview = null, + ) + } catch (e: CancellationException) { + throw e + } catch (_: IllegalArgumentException) { + // getGlanceIdBy: the widget was removed meanwhile. + WidgetConfigUiState.Missing + } catch (_: Exception) { + WidgetConfigUiState.Failed + } + reloadPreview() + } + + /** The size the launcher placed the widget at (portrait), or a default before it has reported one. */ + private fun placedSize(kind: WidgetKind): DpSize { + val options = AppWidgetManager.getInstance(context)?.getAppWidgetOptions(appWidgetId) + val width = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) ?: 0 + val height = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT) ?: 0 + if (width > 0 && height > 0) return DpSize(width.dp, height.dp) + return kind.defaultSize + } + + /** The calendars the app itself shows; the ones hidden there never reach a widget anyway. */ + private suspend fun shownCalendars(): List { + val hidden = calendarPrefs.hiddenCalendarIds.first() + return repository.calendars().first().filter { it.isVisibleInSystem && it.id !in hidden } + } + + fun setAppearance(appearance: WidgetAppearance) = edit { it.copy(draft = it.draft.copy(appearance = appearance)) } + + fun setContent(options: AgendaWidgetOptions) { + edit { it.copy(draft = it.draft.copy(content = options)) } + reloadPreview() + } + + fun setCountdown(options: CountdownOptions) { + edit { it.copy(draft = it.draft.copy(countdown = options)) } + reloadPreview() + } + + /** Loads the events the countdown can be pinned to, unless they're loaded or loading. */ + fun loadCountdownCandidates() { + val ready = _state.value as? WidgetConfigUiState.Ready ?: return + if (ready.candidates is CountdownCandidates.Loading || ready.candidates is CountdownCandidates.Ready) return + edit { it.copy(candidates = CountdownCandidates.Loading) } + viewModelScope.launch { + val candidates = try { + val now = Clock.System.now() + val occurrences = repository.instances(now..now + 365.days).first().filter { it.end > now } + val perEvent = occurrences.groupBy { it.eventId } + CountdownCandidates.Ready( + perEvent.values + .map { CountdownCandidate(it.minBy { o -> o.start }, repeats = it.size > 1) } + .sortedBy { it.event.start }, + ) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + CountdownCandidates.Failed + } + edit { it.copy(candidates = candidates) } + } + } + + fun setCalendarShown(id: Long, shown: Boolean) { + edit { + val hidden = it.draft.hiddenCalendars + it.copy(draft = it.draft.copy(hiddenCalendars = if (shown) hidden - id else hidden + id)) + } + reloadPreview() + } + + private fun edit(change: (WidgetConfigUiState.Ready) -> WidgetConfigUiState.Ready) { + _state.update { if (it is WidgetConfigUiState.Ready) change(it) else it } + } + + private fun reloadPreview() { + val ready = _state.value as? WidgetConfigUiState.Ready ?: return + previewJob?.cancel() + previewJob = viewModelScope.launch { + val preview = try { + loadPreview(ready) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + // The options stay editable; the preview keeps what it had. + edit { it.copy(previewFailed = it.preview == null) } + return@launch + } + edit { it.copy(preview = preview, previewFailed = false) } + } + } + + private suspend fun loadPreview(ready: WidgetConfigUiState.Ready): WidgetPreviewData { + val draft = ready.draft + val settings = prefs.widgetSettings.first() + return when (ready.kind) { + WidgetKind.AGENDA -> + WidgetPreviewData.Agenda(context.loadAgendaWidgetData(settings, draft.content, draft.hiddenCalendars)) + WidgetKind.MONTH -> { + val index = monthIndexOf(today(systemZone())) + val source = context.loadThemedMonthSource(monthWindowCenter(index), draft.hiddenCalendars) + WidgetPreviewData.Month(source.content, index) + } + WidgetKind.QUICK_ADD, WidgetKind.DATE -> WidgetPreviewData.Today(today(systemZone())) + WidgetKind.UPCOMING -> WidgetPreviewData.Days(context.loadUpcoming(draft.loadContext(settings)).content) + WidgetKind.TODAY -> WidgetPreviewData.Days(context.loadTodayList(draft.loadContext(settings)).content) + WidgetKind.MONTH_DAY -> { + val index = monthIndexOf(today(systemZone())) + val source = context.loadThemedMonthSource(monthWindowCenter(index), draft.hiddenCalendars) + WidgetPreviewData.MonthDay(context.loadMonthDay(source, draft.content).content, index) + } + WidgetKind.COUNTDOWN -> + WidgetPreviewData.Countdown(context.loadCountdown(draft.loadContext(settings), draft.countdown, null).content) + WidgetKind.WEEK_STRIP -> WidgetPreviewData.Days(context.loadDays(draft.loadContext(settings), ::weekOf).content) + } + } + + /** Writes the draft into the widget and redraws it, then reports how that went on [events]. */ + fun save() { + val ready = _state.value as? WidgetConfigUiState.Ready ?: return + if (ready.saving) return + edit { it.copy(saving = true) } + viewModelScope.launch { + try { + val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId) + ready.kind.widget().updateWithRefresh(context, glanceId) { state -> + state.putAppearance(ready.draft.appearance) + if (ready.kind.hasContentOptions) state.putAgendaOptions(ready.draft.content) + if (WidgetOption.CALENDARS in ready.kind.options) state.putHiddenCalendars(ready.draft.hiddenCalendars) + if (ready.kind == WidgetKind.COUNTDOWN) state.putCountdownOptions(ready.draft.countdown) + } + _events.send(WidgetConfigEvent.SAVED) + } catch (e: CancellationException) { + throw e + } catch (_: IllegalArgumentException) { + _state.value = WidgetConfigUiState.Missing + } catch (_: Exception) { + edit { it.copy(saving = false) } + _events.send(WidgetConfigEvent.SAVE_FAILED) + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/Countdown.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/Countdown.kt new file mode 100644 index 0000000..b707972 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/Countdown.kt @@ -0,0 +1,96 @@ +package de.jeanlucmakiola.calendula.widget.countdown + +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.isDeclined +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.daysUntil +import kotlinx.datetime.plus +import kotlinx.datetime.toLocalDateTime +import kotlin.math.ceil +import kotlin.math.roundToInt +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant + +/** What a countdown widget counts down to (#439). */ +enum class CountdownMode { NEXT, PINNED } + +data class CountdownOptions( + val mode: CountdownMode = CountdownMode.NEXT, + /** The pinned event or series; a series follows its next occurrence. */ + val eventId: Long? = null, + /** The pinned event's title when it was chosen, for the config screen. */ + val title: String? = null, +) + +enum class CountdownUnit { NOW, MINUTES, HOURS, DAYS } + +/** "in 25 min", "ends in 3 h", "in 12 days": [running] counts to the end instead of the start. */ +data class CountdownLabel(val running: Boolean, val unit: CountdownUnit, val amount: Int) + +private fun target(event: EventInstance, now: Instant): Instant = if (event.start <= now) event.end else event.start + +/** + * The label for [event] at [now]. Under a minute reads "now", under an hour + * counts minutes (rounded up), under a day whole hours (rounded), and beyond + * that calendar days in [zone], so "in 1 day" is always tomorrow. + */ +fun countdownLabel(event: EventInstance, now: Instant, zone: TimeZone): CountdownLabel { + val running = event.start <= now + val target = target(event, now) + val delta = target - now + return when { + delta < 1.minutes -> CountdownLabel(running, CountdownUnit.NOW, 0) + delta < 1.hours -> CountdownLabel(running, CountdownUnit.MINUTES, ceil(delta / 1.minutes).toInt()) + delta < 24.hours -> CountdownLabel(running, CountdownUnit.HOURS, (delta / 1.hours).roundToInt().coerceAtLeast(1)) + else -> { + val days = now.toLocalDateTime(zone).date.daysUntil(target.toLocalDateTime(zone).date) + CountdownLabel(running, CountdownUnit.DAYS, days.coerceAtLeast(1)) + } + } +} + +/** + * When [countdownLabel] next reads differently, so the widget redraws then + * and not before: every minute in the last hour, at each half hour within the + * day, and at midnight or the 24-hour mark beyond that. + */ +fun nextLabelChange(event: EventInstance, now: Instant, zone: TimeZone): Instant { + val target = target(event, now) + val label = countdownLabel(event, now, zone) + val lowerBound: Duration = when (label.unit) { + CountdownUnit.NOW -> Duration.ZERO + CountdownUnit.MINUTES -> (label.amount - 1).minutes + CountdownUnit.HOURS -> maxOf((label.amount - 0.5).hours, 1.hours) + CountdownUnit.DAYS -> { + val midnight = now.toLocalDateTime(zone).date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone) + return minOf(midnight, target - 24.hours).coerceAtLeast(now + 1.minutes) + } + } + return (target - lowerBound).coerceAtLeast(now + 1.minutes) +} + +/** + * The next-event mode's pick: timed, not declined, not over; of those, the one + * whose countdown changes soonest — a running event's end, else a start. + */ +fun pickNextEvent(instances: List, now: Instant): EventInstance? = + instances + .filter { !it.isAllDay && !it.isDeclined && it.end > now } + .minByOrNull { target(it, now) } + +/** A pinned event's occurrence to count to: the running or next one. */ +fun pickPinnedOccurrence(occurrences: List, now: Instant, zone: TimeZone): EventInstance? = + occurrences.filter { it.counted(zone).end > now }.minByOrNull { it.start } + +/** + * The span a countdown counts over. An all-day event is stored on UTC + * midnights; it starts and ends at local midnight of those dates instead. + */ +fun EventInstance.counted(zone: TimeZone): EventInstance = if (!isAllDay) this else copy( + start = start.toLocalDateTime(TimeZone.UTC).date.atStartOfDayIn(zone), + end = end.toLocalDateTime(TimeZone.UTC).date.atStartOfDayIn(zone), +) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownData.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownData.kt new file mode 100644 index 0000000..97c0594 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownData.kt @@ -0,0 +1,167 @@ +package de.jeanlucmakiola.calendula.widget.countdown + +import android.app.AlarmManager +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.net.toUri +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.glance.GlanceId +import androidx.glance.appwidget.GlanceAppWidgetManager +import de.jeanlucmakiola.calendula.data.prefs.is24Hour +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.widget.Themed +import de.jeanlucmakiola.calendula.widget.WidgetLoadContext +import de.jeanlucmakiola.calendula.widget.hasCalendarPermission +import de.jeanlucmakiola.calendula.widget.systemZone +import de.jeanlucmakiola.calendula.widget.today +import de.jeanlucmakiola.calendula.widget.widgetEntryPoint +import de.jeanlucmakiola.calendula.widget.widgetLoadContext +import de.jeanlucmakiola.calendula.widget.widgetState +import de.jeanlucmakiola.calendula.widget.withoutCalendars +import de.jeanlucmakiola.floret.android.goAsync +import de.jeanlucmakiola.floret.glance.updateWithRefresh +import de.jeanlucmakiola.floret.prefs.toEnum +import kotlinx.coroutines.flow.first +import kotlinx.datetime.LocalDate +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.Instant + +private val ModeKey = stringPreferencesKey("cfg_countdown_mode") +private val EventKey = longPreferencesKey("cfg_countdown_event") +private val TitleKey = stringPreferencesKey("cfg_countdown_title") + +internal val Preferences.countdownOptions: CountdownOptions + get() = CountdownOptions(this[ModeKey].toEnum(CountdownMode.NEXT), this[EventKey], this[TitleKey]) + +internal fun MutablePreferences.putCountdownOptions(options: CountdownOptions) { + this[ModeKey] = options.mode.name + if (options.eventId != null) this[EventKey] = options.eventId else remove(EventKey) + if (options.title != null) this[TitleKey] = options.title else remove(TitleKey) +} + +/** How far ahead the next-event mode looks; past this it shows no event. */ +private val NextEventHorizon = 30.days + +/** How far ahead a pinned event's next occurrence is looked for. */ +private val PinnedHorizon = 730.days + +sealed interface CountdownData { + data object NeedsPermission : CountdownData + data class Ready( + val options: CountdownOptions, + /** The occurrence counted to; null when there is none to show. */ + val event: EventInstance?, + val now: Instant, + val today: LocalDate, + val is24Hour: Boolean, + val soften: Boolean, + /** The placed widget, for opening its settings; null in a preview. */ + val appWidgetId: Int?, + ) : CountdownData +} + +internal suspend fun Context.loadCountdown( + load: WidgetLoadContext, + options: CountdownOptions, + appWidgetId: Int?, +): Themed { + val settings = load.settings + if (!hasCalendarPermission()) return Themed(CountdownData.NeedsPermission, settings.themeMode) + val now = Clock.System.now() + val repository = widgetEntryPoint().calendarRepository() + val event = when (options.mode) { + CountdownMode.NEXT -> + pickNextEvent(repository.instances(now..now + NextEventHorizon).first().withoutCalendars(load.hiddenCalendars), now) + CountdownMode.PINNED -> options.eventId?.let { id -> + pickPinnedOccurrence(repository.eventOccurrences(id, now - 1.days..now + PinnedHorizon), now, systemZone()) + } + } + return Themed( + CountdownData.Ready( + options = options, + event = event, + now = now, + today = today(systemZone()), + is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)), + soften = settings.soften, + appWidgetId = appWidgetId, + ), + settings.themeMode, + ) +} + +/** [loadCountdown] for placed widget [id], arming its redraw for when the label next changes. */ +internal suspend fun Context.loadPlacedCountdown(id: GlanceId): Themed { + val appWidgetId = GlanceAppWidgetManager(this).getAppWidgetId(id) + val loaded = loadCountdown(widgetLoadContext(id), widgetState(id).countdownOptions, appWidgetId) + val event = (loaded.content as? CountdownData.Ready)?.event + if (event == null) { + CountdownTicks.cancel(this, appWidgetId) + } else { + val zone = systemZone() + CountdownTicks.schedule(this, appWidgetId, nextLabelChange(event.counted(zone), (loaded.content as CountdownData.Ready).now, zone)) + } + return loaded +} + +/** + * One alarm per countdown widget, for the moment its label changes. Not a + * wake-up alarm: with the screen off nobody sees the widget, so the redraw + * waits for the device to wake. Exact where allowed (the app holds the exact + * alarm permission for reminders), else within a minute. + */ +internal object CountdownTicks { + private const val ACTION_TICK = "de.jeanlucmakiola.calendula.widget.COUNTDOWN_TICK" + + fun schedule(context: Context, appWidgetId: Int, at: Instant) { + val alarms = context.getSystemService(AlarmManager::class.java) ?: return + val intent = pendingIntent(context, appWidgetId) + val millis = at.toEpochMilliseconds() + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarms.canScheduleExactAlarms()) { + alarms.setExact(AlarmManager.RTC, millis, intent) + } else { + alarms.setWindow(AlarmManager.RTC, millis, 60_000L, intent) + } + } + + fun cancel(context: Context, appWidgetId: Int) { + context.getSystemService(AlarmManager::class.java)?.cancel(pendingIntent(context, appWidgetId)) + } + + private fun pendingIntent(context: Context, appWidgetId: Int): PendingIntent = PendingIntent.getBroadcast( + context, + 0, + Intent(context, CountdownTickReceiver::class.java) + .setAction(ACTION_TICK) + // Keys the PendingIntent per widget. + .setData("calendula://countdown/$appWidgetId".toUri()) + .putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) +} + +/** Reloads the countdown widget whose label just changed; the load arms the next tick. */ +class CountdownTickReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) return + val appContext = context.applicationContext + goAsync { + try { + val glanceId = GlanceAppWidgetManager(appContext).getGlanceIdBy(appWidgetId) + CountdownWidget().updateWithRefresh(appContext, glanceId) + } catch (_: IllegalArgumentException) { + // The widget is gone. + CountdownTicks.cancel(appContext, appWidgetId) + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownWidget.kt new file mode 100644 index 0000000..7bca448 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownWidget.kt @@ -0,0 +1,230 @@ +package de.jeanlucmakiola.calendula.widget.countdown + +import de.jeanlucmakiola.calendula.widget.WidgetKind +import de.jeanlucmakiola.calendula.widget.previewSizeOf +import de.jeanlucmakiola.calendula.widget.provideSamplePreview +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.res.Resources +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.datastore.preferences.core.Preferences +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.LocalContext +import androidx.glance.action.Action +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.provideContent +import androidx.glance.background +import androidx.glance.currentState +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxHeight +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.padding +import androidx.glance.layout.width +import androidx.glance.state.PreferencesGlanceStateDefinition +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import de.jeanlucmakiola.calendula.MainActivity +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.eventAccent +import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver +import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot +import de.jeanlucmakiola.calendula.widget.WidgetAppearance +import de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity +import de.jeanlucmakiola.calendula.widget.formatWidgetTime +import de.jeanlucmakiola.calendula.widget.relativeDayLabel +import de.jeanlucmakiola.calendula.widget.systemZone +import de.jeanlucmakiola.calendula.widget.widgetAppearance +import de.jeanlucmakiola.calendula.widget.widgetCardColor +import de.jeanlucmakiola.floret.glance.LocalWidgetBackground +import de.jeanlucmakiola.floret.glance.WidgetBackground +import de.jeanlucmakiola.floret.glance.WidgetData +import de.jeanlucmakiola.floret.glance.WidgetMessage +import de.jeanlucmakiola.floret.glance.currentRefreshStamp +import de.jeanlucmakiola.floret.glance.refreshStamp +import de.jeanlucmakiola.floret.glance.themedColor +import de.jeanlucmakiola.floret.prefs.ThemeMode +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.minus +import kotlinx.datetime.toLocalDateTime + +/** + * Countdown (#439): to the next timed event, or to a chosen event or series + * ("Vacation · in 12 days"). Redraws when its label changes, see + * [nextLabelChange] and [CountdownTicks]. Tapping opens the event. + */ +class CountdownWidget : GlanceAppWidget() { + + override val stateDefinition = PreferencesGlanceStateDefinition + + override val sizeMode = SizeMode.Single + + override val previewSizeMode = previewSizeOf(WidgetKind.COUNTDOWN) + + override suspend fun providePreview(context: Context, widgetCategory: Int) = + provideSamplePreview(context, WidgetKind.COUNTDOWN) + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val widgetData = WidgetData(context.loadPlacedCountdown(id), refreshStamp(context, id)) + provideContent { + val loaded = widgetData.current(currentRefreshStamp()) { context.loadPlacedCountdown(id) } + CountdownWidgetContent(loaded.content, currentState().widgetAppearance, loaded.appTheme) + } + } +} + +class CountdownWidgetReceiver : CalendulaWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = CountdownWidget() + + override fun onDeleted(context: Context, appWidgetIds: IntArray) { + super.onDeleted(context, appWidgetIds) + appWidgetIds.forEach { CountdownTicks.cancel(context, it) } + } +} + +@Composable +internal fun CountdownWidgetContent(data: CountdownData, appearance: WidgetAppearance, appTheme: ThemeMode) { + CalendulaWidgetRoot(appearance, appTheme) { + val context = LocalContext.current + when (data) { + CountdownData.NeedsPermission -> WidgetMessage(context.getString(R.string.widget_needs_permission)) + is CountdownData.Ready -> { + val event = data.event + when { + event != null -> EventCountdown(event, data) + data.options.mode == CountdownMode.PINNED && data.options.eventId == null -> + Message(context.getString(R.string.widget_countdown_choose), configAction(context, data.appWidgetId)) + data.options.mode == CountdownMode.PINNED -> + Message(context.getString(R.string.widget_countdown_over, data.options.title.orEmpty()), configAction(context, data.appWidgetId)) + else -> Message( + context.getString(R.string.widget_upcoming_empty), + actionStartActivity(MainActivity.openViewIntent(context, null)), + ) + } + } + } + } +} + +private fun configAction(context: Context, appWidgetId: Int?): Action? = + appWidgetId?.takeIf { it != AppWidgetManager.INVALID_APPWIDGET_ID } + ?.let { actionStartActivity(WidgetConfigActivity.intent(context, it)) } + +@Composable +private fun Message(text: String, action: Action?) { + val modifier = if (action != null) GlanceModifier.clickable(action) else GlanceModifier + WidgetMessage(text, modifier = modifier) +} + +@Composable +private fun EventCountdown(event: EventInstance, data: CountdownData.Ready) { + val context = LocalContext.current + val label = countdownLabel(event.counted(systemZone()), data.now, systemZone()) + val open = actionStartActivity( + MainActivity.openEventIntent( + context = context, + eventId = event.eventId, + beginMillis = event.start.toEpochMilliseconds(), + endMillis = event.end.toEpochMilliseconds(), + source = CalendarView.Agenda, + ), + ) + // Over a wallpaper the text sits on its own card, as every other widget's rows do. + val card = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) { + GlanceModifier.cornerRadius(16.dp).background(widgetCardColor()) + } else { + GlanceModifier + } + Box(modifier = GlanceModifier.fillMaxSize().padding(6.dp)) { + Row( + modifier = card.fillMaxSize().clickable(open).padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = GlanceModifier + .width(4.dp) + .fillMaxHeight() + .cornerRadius(2.dp) + .background( + themedColor( + day = eventAccent(event.color, dark = false, data.soften), + night = eventAccent(event.color, dark = true, data.soften), + ), + ), + ) {} + Spacer(GlanceModifier.width(10.dp)) + Column(modifier = GlanceModifier.defaultWeight(), verticalAlignment = Alignment.CenterVertically) { + Text( + text = event.title.ifBlank { context.getString(R.string.event_untitled) }, + maxLines = 1, + style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp, fontWeight = FontWeight.Medium), + ) + Text( + text = countdownText(context.resources, label), + maxLines = 1, + style = TextStyle(color = GlanceTheme.colors.primary, fontSize = 22.sp, fontWeight = FontWeight.Bold), + ) + Text( + text = whenText(context, event, data), + maxLines = 1, + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp), + ) + } + } + } +} + +internal fun countdownText(res: Resources, label: CountdownLabel): String = when (label.unit) { + CountdownUnit.NOW -> res.getString(if (label.running) R.string.widget_countdown_ending_now else R.string.widget_countdown_now) + CountdownUnit.MINUTES -> res.getQuantityString( + if (label.running) R.plurals.widget_countdown_ends_minutes else R.plurals.widget_countdown_in_minutes, + label.amount, + label.amount, + ) + CountdownUnit.HOURS -> res.getQuantityString( + if (label.running) R.plurals.widget_countdown_ends_hours else R.plurals.widget_countdown_in_hours, + label.amount, + label.amount, + ) + CountdownUnit.DAYS -> when { + label.amount == 1 && label.running -> res.getString(R.string.widget_countdown_ends_tomorrow) + label.amount == 1 -> res.getString(R.string.widget_countdown_tomorrow) + else -> res.getQuantityString( + if (label.running) R.plurals.widget_countdown_ends_days else R.plurals.widget_countdown_in_days, + label.amount, + label.amount, + ) + } +} + +/** When it starts, or ends once running: "Today · 14:00", "Fri · All day". */ +private fun whenText(context: Context, event: EventInstance, data: CountdownData.Ready): String { + val counted = event.counted(systemZone()) + val running = counted.start <= data.now + val at = if (running) counted.end else counted.start + val date = at.toLocalDateTime(systemZone()).date + val day = relativeDayLabel(context, date, data.today) + return when { + event.isAllDay && running -> context.getString( + R.string.widget_countdown_until, + relativeDayLabel(context, date.minus(1, DateTimeUnit.DAY), data.today), + ) + event.isAllDay -> "$day · ${context.getString(R.string.event_detail_all_day)}" + running -> context.getString(R.string.agenda_span_ends, "$day · ${formatWidgetTime(at, data.is24Hour)}") + else -> "$day · ${formatWidgetTime(at, data.is24Hour)}" + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/date/DateWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/date/DateWidget.kt new file mode 100644 index 0000000..e75120f --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/date/DateWidget.kt @@ -0,0 +1,130 @@ +package de.jeanlucmakiola.calendula.widget.date + +import de.jeanlucmakiola.calendula.widget.WidgetKind +import de.jeanlucmakiola.calendula.widget.previewSizeOf +import de.jeanlucmakiola.calendula.widget.provideSamplePreview +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.datastore.preferences.core.Preferences +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.provideContent +import androidx.glance.currentState +import androidx.glance.layout.Alignment +import androidx.glance.layout.Column +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.semantics.contentDescription +import androidx.glance.semantics.semantics +import androidx.glance.state.PreferencesGlanceStateDefinition +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextAlign +import androidx.glance.text.TextStyle +import de.jeanlucmakiola.calendula.MainActivity +import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver +import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot +import de.jeanlucmakiola.calendula.widget.WidgetAppearance +import de.jeanlucmakiola.calendula.widget.loadToday +import de.jeanlucmakiola.calendula.widget.widgetAppearance +import de.jeanlucmakiola.calendula.widget.widgetLocale +import de.jeanlucmakiola.floret.glance.LocalWidgetBackground +import de.jeanlucmakiola.floret.glance.WidgetBackground +import de.jeanlucmakiola.floret.glance.WidgetData +import de.jeanlucmakiola.floret.glance.WidgetShape +import de.jeanlucmakiola.floret.glance.WidgetShapedBox +import de.jeanlucmakiola.floret.glance.currentRefreshStamp +import de.jeanlucmakiola.floret.glance.refreshStamp +import de.jeanlucmakiola.floret.glance.wallpaperBacking +import de.jeanlucmakiola.floret.locale.localizedDateFormatter +import de.jeanlucmakiola.floret.prefs.ThemeMode +import kotlinx.datetime.LocalDate +import kotlinx.datetime.toJavaDayOfWeek +import kotlinx.datetime.toJavaLocalDate +import java.time.format.TextStyle as JavaTextStyle + +/** 1×1 date (#376): today's weekday above its number on an expressive shape; opens today. */ +class DateWidget : GlanceAppWidget() { + + override val stateDefinition = PreferencesGlanceStateDefinition + + override val sizeMode = SizeMode.Exact + + override val previewSizeMode = previewSizeOf(WidgetKind.DATE) + + override suspend fun providePreview(context: Context, widgetCategory: Int) = + provideSamplePreview(context, WidgetKind.DATE) + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val widgetData = WidgetData(context.loadToday(), refreshStamp(context, id)) + provideContent { + val loaded = widgetData.current(currentRefreshStamp()) { context.loadToday() } + DateWidgetContent(loaded.content, currentState().widgetAppearance, loaded.appTheme) + } + } +} + +class DateWidgetReceiver : CalendulaWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = DateWidget() +} + +private val WeekdayLine = 18.dp + +@Composable +internal fun DateWidgetContent(today: LocalDate, appearance: WidgetAppearance, appTheme: ThemeMode) { + CalendulaWidgetRoot(appearance, appTheme) { + val context = LocalContext.current + val size = LocalSize.current + val fontScale = context.resources.configuration.fontScale + val side = (minOf(size.width, size.height - WeekdayLine * fontScale) - 8.dp).coerceIn(32.dp, 120.dp) + val label = localizedDateFormatter(widgetLocale(), "EEEEdMMMM").format(today.toJavaLocalDate()) + val weekday = TextStyle( + color = GlanceTheme.colors.onSurface, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + ) + Column( + modifier = GlanceModifier + .fillMaxSize() + .clickable(actionStartActivity(MainActivity.openDateIntent(context, today, CalendarView.Day))) + .semantics { contentDescription = label }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalAlignment = Alignment.CenterVertically, + ) { + val name = today.dayOfWeek.toJavaDayOfWeek().getDisplayName(JavaTextStyle.SHORT, widgetLocale()) + val backing = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) { + GlanceModifier.wallpaperBacking(radius = 8.dp).padding(horizontal = 6.dp) + } else { + GlanceModifier + } + Text(text = name.uppercase(widgetLocale()), maxLines = 1, style = weekday, modifier = backing) + Spacer(GlanceModifier.height(2.dp)) + WidgetShapedBox(shape = WidgetShape.COOKIE, color = GlanceTheme.colors.primary, size = side) { + Text( + text = today.day.toString(), + maxLines = 1, + style = TextStyle( + color = GlanceTheme.colors.onPrimary, + // Sized to the shape, so held against the font scale. + fontSize = (side.value * 0.45f / fontScale).sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ), + ) + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt new file mode 100644 index 0000000..e43d95c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGrid.kt @@ -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(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 mergeGaps(columns: Int, cellAt: (Int) -> GridCell?): List> { + val cells = mutableListOf>() + 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 = 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(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> = 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, lanes: Int): List = 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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt new file mode 100644 index 0000000..e8aaa41 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthGridViews.kt @@ -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, + 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, +) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt index c72e851..f45af96 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidget.kt @@ -1,76 +1,83 @@ package de.jeanlucmakiola.calendula.widget.month -import de.jeanlucmakiola.calendula.domain.isDeclined -import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration +import de.jeanlucmakiola.calendula.widget.WidgetKind +import de.jeanlucmakiola.calendula.widget.previewSizeOf +import de.jeanlucmakiola.calendula.widget.provideSamplePreview import android.content.Context -import android.content.res.Configuration import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.intPreferencesKey -import androidx.glance.ColorFilter import androidx.glance.GlanceId import androidx.glance.GlanceModifier import androidx.glance.GlanceTheme -import androidx.glance.Image import androidx.glance.ImageProvider import androidx.glance.LocalContext import androidx.glance.LocalSize import androidx.glance.action.ActionParameters import androidx.glance.action.actionParametersOf -import androidx.glance.action.clickable import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.AndroidRemoteViews +import de.jeanlucmakiola.floret.glance.WidgetBackground +import de.jeanlucmakiola.floret.glance.LocalWidgetBackground +import androidx.glance.background +import androidx.glance.appwidget.cornerRadius import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.action.actionRunCallback import androidx.glance.appwidget.action.actionStartActivity -import androidx.glance.appwidget.cornerRadius import androidx.glance.appwidget.provideContent import androidx.glance.appwidget.state.getAppWidgetState import androidx.glance.appwidget.state.updateAppWidgetState -import androidx.glance.background import androidx.glance.currentState import androidx.glance.layout.Alignment import androidx.glance.layout.Box import androidx.glance.layout.Column import androidx.glance.layout.Row import androidx.glance.layout.Spacer -import androidx.glance.layout.fillMaxHeight import androidx.glance.layout.fillMaxSize import androidx.glance.layout.fillMaxWidth import androidx.glance.layout.height import androidx.glance.layout.padding import androidx.glance.layout.size -import androidx.glance.layout.width import androidx.glance.state.PreferencesGlanceStateDefinition -import androidx.glance.text.FontWeight +import de.jeanlucmakiola.floret.glance.LocalWidgetThemeMode +import de.jeanlucmakiola.floret.glance.WidgetColorRole +import de.jeanlucmakiola.floret.glance.remoteColor +import de.jeanlucmakiola.floret.glance.WidgetActionStyle +import de.jeanlucmakiola.floret.glance.WidgetHeader +import de.jeanlucmakiola.floret.glance.WidgetHeaderAction +import de.jeanlucmakiola.floret.glance.WidgetHeaderActionGap +import de.jeanlucmakiola.floret.glance.WidgetHeaderTextAction import androidx.glance.text.Text import androidx.glance.text.TextAlign import androidx.glance.text.TextStyle -import androidx.glance.unit.ColorProvider import de.jeanlucmakiola.calendula.MainActivity import de.jeanlucmakiola.calendula.R -import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle -import de.jeanlucmakiola.calendula.ui.month.MonthWeek import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks -import de.jeanlucmakiola.calendula.ui.common.eventFill -import de.jeanlucmakiola.calendula.ui.common.eventInk -import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme +import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot +import de.jeanlucmakiola.calendula.widget.WidgetAppearance +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.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.systemZone import de.jeanlucmakiola.calendula.widget.today -import de.jeanlucmakiola.calendula.widget.widgetEntryPoint -import kotlinx.coroutines.flow.first import androidx.compose.ui.unit.Dp import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate @@ -83,12 +90,16 @@ import java.util.Locale /** Per-widget state: the displayed month as `year * 12 + monthOrdinal`. */ 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 +/** + * Height around the week rows: body padding, the header's bottom padding and + * spacer and the weekday pills' padding, then the header (its 28dp pills, or a title line in its transparent + * pill when that's taller) and the weekday row, the text lines at [fontScale]. + */ +private fun gridChromeHeight(fontScale: Float): Dp = + 21.dp + maxOf(28.dp, 19.dp * fontScale + 8.dp) + 15.dp * fontScale + private fun currentMonthIndex(zone: TimeZone): Int = monthIndexOf(today(zone)) private fun yearMonthOf(index: Int): YearMonth = @@ -99,9 +110,9 @@ private fun yearMonthOf(index: Int): YearMonth = * event bars and titled single-day pills (the in-app lane layout via * [layoutMonthWeeks]), and prev/next/today navigation. * - * Columns are sized explicitly from [LocalSize] (hence [SizeMode.Exact]) so a - * multi-day span renders as a single Box spanning its columns — connected, no - * inter-cell seam, with rounded end caps. The displayed month lives in Glance + * The week rows are one plain RemoteViews tree ([monthGridViews]) embedded in + * the Glance widget, so a busy month isn't held to Glance's view-id pool (#372); + * the lanes each week shows come from the widget's height. The displayed month lives in Glance * state and is read reactively in the composition ([currentState]) so the arrows * move it via plain recomposition, not a (here-unreliable) widget session reload. * @@ -112,7 +123,7 @@ private fun yearMonthOf(index: Int): YearMonth = * the launcher rebinds. [SizeMode.Exact] serialises the grid once per size the * host reports, so the ceiling arrives at half the view count you would expect: a * grid that emitted a view per cell blew past it after three taps (#214). Every - * row here emits only what it fills. + * row here emits only what it fills, and lanes stop at what the height shows. */ class MonthWidget : GlanceAppWidget() { @@ -122,27 +133,60 @@ class MonthWidget : GlanceAppWidget() { * Exact rather than [SizeMode.Single] or [SizeMode.Responsive], both of which * return early from `GlanceAppWidget.resize` — under those the grid is never * recomposed for a new size and a resized widget clips instead of reflowing. - * Exact costs one serialised copy per host size, which is affordable now that - * a rendering is ~190 views rather than ~740. + * Exact costs one serialised copy per host size, so the grid draws only the + * lanes the size can show, folding the rest into "+N". */ override val sizeMode = SizeMode.Exact + override val previewSizeMode = previewSizeOf(WidgetKind.MONTH) + + override suspend fun providePreview(context: Context, widgetCategory: Int) = + provideSamplePreview(context, WidgetKind.MONTH) + override suspend fun provideGlance(context: Context, id: GlanceId) { val shown = getAppWidgetState(context, id)[MONTH_INDEX_KEY] - val source = context.loadMonthWidgetSource(shown) - val dark = (context.resources.configuration.uiMode and - Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES - // Read fresh (not through the cached source) so toggling the softener - // redraws with the new choice; it's one cheap DataStore read. - val soften = context.widgetEntryPoint().settingsPrefs().softenCalendarColors.first() + ?: currentMonthIndex(systemZone()) + val stamp = refreshStamp(context, id) + val initialCenter = monthWindowCenter(shown) + // Shared by every size Exact composes, so each loads nothing of its own. + val widgetData = WidgetData(context.loadCachedMonthSource(id, initialCenter, stamp), initialCenter, stamp) provideContent { - CalendulaGlanceTheme { - MonthWidgetBody(source = source, dark = dark, soften = soften) + val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(systemZone()) + // 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().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). * @@ -179,57 +223,53 @@ class ShiftMonthAction : ActionCallback { } } -/** Jump the displayed month back to the current month. See [ShiftMonthAction]. */ +/** Jump the displayed month back to the current month, reloading it. See [ShiftMonthAction]. */ class ResetMonthAction : ActionCallback { override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { updateAppWidgetState(context, glanceId) { prefs -> prefs.remove(MONTH_INDEX_KEY) } - MonthWidget().update(context.applicationContext, glanceId) + MonthWidget().updateWithRefresh(context.applicationContext, glanceId) } } @Composable -private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Boolean) { +private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) { Column( modifier = GlanceModifier .fillMaxSize() - .background(GlanceTheme.colors.surface) .padding(horizontal = GRID_HPADDING, vertical = 6.dp), ) { when (source) { MonthWidgetSource.NeedsPermission -> { - MonthHeader(label = "Calendula") + MonthHeader(label = "Calendula", today = today(systemZone())) PermissionMessage() } is MonthWidgetSource.Ready -> { val zone = systemZone() - val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone) 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 weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone) val context = LocalContext.current - var loaded by remember(source) { mutableStateOf(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. - val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7 - val weeks = layoutMonthWeeks(ym, loaded.weekStart, loaded.instances, zone) + 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)) - Spacer(GlanceModifier.height(2.dp)) - WeekdayHeader(weekStart = loaded.weekStart, colW = colW) - weeks.forEach { week -> - WeekRow( - week = week, - currentMonth = ym.month, - today = loaded.today, - dark = dark, - soften = soften, - colW = colW, - modifier = GlanceModifier.defaultWeight(), + MonthHeader(label = monthLabel(ym, source.today.year), today = source.today) + Spacer(GlanceModifier.height(4.dp)) + // No backing behind the grid: the weekday pills and day cells + // each carry their own fill, so a transparent widget stays + // readable without one blob over the wallpaper (#292). + Column(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) { + WeekdayHeader(weekStart = source.weekStart) + AndroidRemoteViews( + remoteViews = monthGridViews(context, layouts, ym.month, source.today, source.soften, colors, mode), + modifier = GlanceModifier.fillMaxWidth().defaultWeight(), ) } } @@ -238,89 +278,79 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Bo } @Composable -private fun MonthHeader(label: String) { +private fun MonthHeader(label: String, today: LocalDate) { val context = LocalContext.current - Row( - modifier = GlanceModifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, + // Tap the month/year title to open the app on the month view (issue #18), + // rooted over the default home so backing out returns there. + WidgetHeader( + title = label, + titleAction = actionStartActivity(MainActivity.openViewIntent(context, CalendarView.Month)), + modifier = GlanceModifier.padding(bottom = 2.dp), + titleInset = 4.dp, ) { - HeaderIcon( - resId = R.drawable.ic_widget_chevron_left, + WidgetHeaderAction( + icon = ImageProvider(R.drawable.ic_widget_chevron_left), contentDescription = context.getString(R.string.widget_prev_month), - onClick = GlanceModifier.clickable( - actionRunCallback( - actionParametersOf(ShiftMonthAction.deltaKey to -1), - ), - ), + action = actionRunCallback(actionParametersOf(ShiftMonthAction.deltaKey to -1)), + style = WidgetActionStyle.TONAL, ) - // Tap the month/year title to open the app on the month view (issue #18), - // rooted over the default home so backing out returns there. - Text( - text = label, - style = TextStyle( - color = GlanceTheme.colors.primary, - fontSize = 15.sp, - fontWeight = FontWeight.Medium, - textAlign = TextAlign.Center, - ), - modifier = GlanceModifier - .defaultWeight() - .clickable( - actionStartActivity( - MainActivity.openViewIntent(context, CalendarView.Month), - ), - ), - ) - // The "today" button snaps the grid back to the current month in place. - HeaderIcon( - resId = R.drawable.ic_widget_today, + WidgetHeaderActionGap() + // The "today" pill carries today's number like the app icon, and snaps + // the grid back to the current month in place. + WidgetHeaderTextAction( + text = today.day.toString(), contentDescription = context.getString(R.string.widget_today), - onClick = GlanceModifier.clickable(actionRunCallback()), + action = actionRunCallback(), + style = WidgetActionStyle.TONAL, ) - HeaderIcon( - resId = R.drawable.ic_widget_chevron_right, + WidgetHeaderActionGap() + WidgetHeaderAction( + icon = ImageProvider(R.drawable.ic_widget_chevron_right), contentDescription = context.getString(R.string.widget_next_month), - onClick = GlanceModifier.clickable( - actionRunCallback( - actionParametersOf(ShiftMonthAction.deltaKey to 1), - ), - ), + action = actionRunCallback(actionParametersOf(ShiftMonthAction.deltaKey to 1)), + style = WidgetActionStyle.TONAL, + ) + WidgetHeaderActionGap() + WidgetHeaderAction( + icon = ImageProvider(R.drawable.ic_widget_add), + contentDescription = context.getString(R.string.widget_new_event), + action = actionStartActivity(MainActivity.openCreateIntent(context, today)), ) } } @Composable -private fun HeaderIcon(resId: Int, contentDescription: String, onClick: GlanceModifier) { - Box( - modifier = GlanceModifier.size(40.dp).then(onClick), - contentAlignment = Alignment.Center, - ) { - Image( - provider = ImageProvider(resId), - contentDescription = contentDescription, - colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant), - modifier = GlanceModifier.size(20.dp), - ) - } -} - -@Composable -private fun WeekdayHeader(weekStart: DayOfWeek, colW: Dp) { - Row(modifier = GlanceModifier.fillMaxWidth()) { +private fun WeekdayHeader(weekStart: DayOfWeek) { + Row(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 1.dp)) { weekdayNarrowNames(weekStart).forEach { name -> - Text( - text = name, - style = TextStyle( - color = GlanceTheme.colors.onSurfaceVariant, - fontSize = 11.sp, - textAlign = TextAlign.Center, - ), - modifier = GlanceModifier.width(colW), - ) + Box(modifier = GlanceModifier.defaultWeight().padding(horizontal = 1.dp)) { + Text( + text = name, + style = TextStyle( + color = GlanceTheme.colors.onPrimaryContainer, + fontSize = 11.sp, + textAlign = TextAlign.Center, + ), + modifier = GlanceModifier + .fillMaxWidth() + .cornerRadius(8.dp) + .background(GlanceTheme.colors.primaryContainer) + .padding(vertical = 1.dp), + ) + } } } } +/** The fill of a day cell: one tone off a filled root, the widget surface over a wallpaper. */ +private fun monthCellRole(background: WidgetBackground): WidgetColorRole = when (background) { + WidgetBackground.SOLID -> WidgetColorRole.SURFACE_VARIANT + WidgetBackground.TINTED, WidgetBackground.TRANSPARENT -> WidgetColorRole.WIDGET_BACKGROUND +} + +@Composable +private fun monthCellRole(): WidgetColorRole = monthCellRole(LocalWidgetBackground.current) + /** Narrow weekday initials starting at [weekStart], in the device locale. * Computed outside the composable so the locale read stays observable-safe. */ private fun weekdayNarrowNames(weekStart: DayOfWeek): List { @@ -331,266 +361,6 @@ private fun weekdayNarrowNames(weekStart: DayOfWeek): List { } } -@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 = 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 { - val cells = mutableListOf() - 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, 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 private fun PermissionMessage() { val context = LocalContext.current @@ -605,6 +375,7 @@ private fun PermissionMessage() { fontSize = 14.sp, textAlign = TextAlign.Center, ), + modifier = GlanceModifier.wallpaperBacking().padding(horizontal = 12.dp, vertical = 8.dp), ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt index 08e4a3e..f645a15 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/month/MonthWidgetReceiver.kt @@ -1,46 +1,9 @@ package de.jeanlucmakiola.calendula.widget.month -import android.appwidget.AppWidgetManager -import android.content.Context import androidx.glance.appwidget.GlanceAppWidget -import androidx.glance.appwidget.GlanceAppWidgetReceiver -import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler +import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver -/** - * Host-facing receiver for the month widget. Declared in the manifest with the - * `appwidget_info_month` provider metadata; delegates rendering to [MonthWidget]. - */ -class MonthWidgetReceiver : GlanceAppWidgetReceiver() { +/** Host-facing receiver for the month widget; rendering is [MonthWidget]'s. */ +class MonthWidgetReceiver : CalendulaWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = MonthWidget() - - /** First month widget placed — start rolling "today" over at midnight (#228). */ - override fun onEnabled(context: Context) { - super.onEnabled(context) - WidgetRolloverScheduler.sync(context) - } - - /** - * Last month widget removed. [WidgetRolloverScheduler.sync] cancels only if - * no agenda widget is left either. - */ - override fun onDisabled(context: Context) { - super.onDisabled(context) - WidgetRolloverScheduler.sync(context) - } - - /** - * The `updatePeriodMillis` backstop is the one wake-up the *system* still - * owns, so it doubles as the alarm's self-heal: anything that drops a - * pending alarm without a broadcast (force-stop, battery restriction, an OEM - * freeze) is repaired here rather than on the next app open. Re-arming - * closer to midnight also narrows the inexact delivery window. - */ - override fun onUpdate( - context: Context, - appWidgetManager: AppWidgetManager, - appWidgetIds: IntArray, - ) { - super.onUpdate(context, appWidgetManager, appWidgetIds) - WidgetRolloverScheduler.sync(context) - } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/quickadd/QuickAddWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/quickadd/QuickAddWidget.kt new file mode 100644 index 0000000..97a4642 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/quickadd/QuickAddWidget.kt @@ -0,0 +1,92 @@ +package de.jeanlucmakiola.calendula.widget.quickadd + +import de.jeanlucmakiola.calendula.widget.WidgetKind +import de.jeanlucmakiola.calendula.widget.previewSizeOf +import de.jeanlucmakiola.calendula.widget.provideSamplePreview +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.datastore.preferences.core.Preferences +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.provideContent +import androidx.glance.currentState +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.fillMaxSize +import androidx.glance.state.PreferencesGlanceStateDefinition +import de.jeanlucmakiola.calendula.MainActivity +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot +import de.jeanlucmakiola.calendula.widget.WidgetAppearance +import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver +import de.jeanlucmakiola.calendula.widget.loadToday +import de.jeanlucmakiola.calendula.widget.widgetAppearance +import de.jeanlucmakiola.floret.glance.WidgetData +import de.jeanlucmakiola.floret.glance.WidgetShape +import de.jeanlucmakiola.floret.glance.WidgetShapedIconButton +import de.jeanlucmakiola.floret.glance.currentRefreshStamp +import de.jeanlucmakiola.floret.glance.refreshStamp +import de.jeanlucmakiola.floret.prefs.ThemeMode +import kotlinx.datetime.LocalDate + +/** + * 1×1 quick add (#435): one shaped "+" that opens the new-event editor on + * today, with the same defaults as the app's own new-event button. + */ +class QuickAddWidget : GlanceAppWidget() { + + override val stateDefinition = PreferencesGlanceStateDefinition + + // Exact: the shape fills whatever square the launcher gives it. + override val sizeMode = SizeMode.Exact + + override val previewSizeMode = previewSizeOf(WidgetKind.QUICK_ADD) + + override suspend fun providePreview(context: Context, widgetCategory: Int) = + provideSamplePreview(context, WidgetKind.QUICK_ADD) + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val widgetData = WidgetData(context.loadToday(), refreshStamp(context, id)) + provideContent { + // The rollover moves the stamp, so the editor opens on the new day. + val loaded = widgetData.current(currentRefreshStamp()) { context.loadToday() } + QuickAddWidgetContent(loaded.content, currentState().widgetAppearance, loaded.appTheme) + } + } +} + +class QuickAddWidgetReceiver : CalendulaWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = QuickAddWidget() +} + +@Composable +internal fun QuickAddWidgetContent(today: LocalDate, appearance: WidgetAppearance, appTheme: ThemeMode) { + CalendulaWidgetRoot(appearance, appTheme) { + val context = LocalContext.current + val size = LocalSize.current + val action = actionStartActivity(MainActivity.openCreateIntent(context, today)) + Box( + modifier = GlanceModifier.fillMaxSize().clickable(action), + contentAlignment = Alignment.Center, + ) { + WidgetShapedIconButton( + shape = WidgetShape.SUNNY, + icon = ImageProvider(R.drawable.ic_widget_add), + contentDescription = context.getString(R.string.widget_new_event), + action = action, + color = GlanceTheme.colors.primary, + iconTint = GlanceTheme.colors.onPrimary, + size = (minOf(size.width, size.height) - 12.dp).coerceIn(36.dp, 120.dp), + ) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/split/MonthDayWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/split/MonthDayWidget.kt new file mode 100644 index 0000000..b6ef071 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/split/MonthDayWidget.kt @@ -0,0 +1,451 @@ +package de.jeanlucmakiola.calendula.widget.split + +import de.jeanlucmakiola.calendula.widget.WidgetKind +import de.jeanlucmakiola.calendula.widget.previewSizeOf +import de.jeanlucmakiola.calendula.widget.provideSamplePreview +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.action.ActionParameters +import androidx.glance.action.actionParametersOf +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.action.ActionCallback +import androidx.glance.appwidget.action.actionRunCallback +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.lazy.LazyColumn +import androidx.glance.appwidget.lazy.items +import androidx.glance.appwidget.provideContent +import androidx.glance.appwidget.state.updateAppWidgetState +import androidx.glance.background +import androidx.glance.currentState +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxHeight +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.width +import androidx.glance.state.PreferencesGlanceStateDefinition +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextAlign +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider +import de.jeanlucmakiola.calendula.MainActivity +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay +import de.jeanlucmakiola.calendula.domain.hasEnded +import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays +import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle +import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions +import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver +import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot +import de.jeanlucmakiola.calendula.widget.MonthWidgetSource +import de.jeanlucmakiola.calendula.widget.Themed +import de.jeanlucmakiola.calendula.widget.WidgetAppearance +import de.jeanlucmakiola.calendula.widget.WidgetEventRow +import de.jeanlucmakiola.calendula.widget.widgetLoadContext +import de.jeanlucmakiola.calendula.widget.compactRowMetrics +import de.jeanlucmakiola.calendula.widget.loadCachedMonthSource +import de.jeanlucmakiola.calendula.widget.monthIndexOf +import de.jeanlucmakiola.calendula.widget.monthWindowCenter +import de.jeanlucmakiola.calendula.widget.relativeDayLabel +import de.jeanlucmakiola.calendula.widget.systemZone +import de.jeanlucmakiola.calendula.widget.today +import de.jeanlucmakiola.calendula.widget.weekOf +import de.jeanlucmakiola.calendula.widget.widgetAppearance +import de.jeanlucmakiola.calendula.widget.widgetLocale +import de.jeanlucmakiola.calendula.widget.widgetCardColor +import de.jeanlucmakiola.calendula.widget.widgetEntryPoint +import de.jeanlucmakiola.calendula.widget.widgetSettings +import de.jeanlucmakiola.calendula.widget.widgetState +import de.jeanlucmakiola.floret.locale.localizedDateFormatter +import kotlinx.datetime.toJavaLocalDate +import de.jeanlucmakiola.calendula.widget.widgetTimeLabel +import de.jeanlucmakiola.calendula.data.prefs.is24Hour +import de.jeanlucmakiola.floret.components.positionOf +import de.jeanlucmakiola.floret.glance.LocalWidgetBackground +import de.jeanlucmakiola.floret.glance.WidgetActionStyle +import de.jeanlucmakiola.floret.glance.WidgetBackground +import de.jeanlucmakiola.floret.glance.WidgetData +import de.jeanlucmakiola.floret.glance.WidgetHeader +import de.jeanlucmakiola.floret.glance.WidgetHeaderAction +import de.jeanlucmakiola.floret.glance.WidgetHeaderActionGap +import de.jeanlucmakiola.floret.glance.WidgetHeaderTextAction +import de.jeanlucmakiola.floret.glance.WidgetMessage +import de.jeanlucmakiola.floret.glance.currentRefreshStamp +import de.jeanlucmakiola.floret.glance.refreshStamp +import de.jeanlucmakiola.floret.glance.wallpaperBacking +import de.jeanlucmakiola.floret.prefs.ThemeMode +import kotlinx.coroutines.flow.first +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.plus +import kotlinx.datetime.toJavaDayOfWeek +import kotlin.time.Clock +import kotlin.time.Instant +import java.time.format.TextStyle as JavaTextStyle + +/** Per-widget state: the shown month (`year * 12 + monthOrdinal`) and the selected day; both follow today when absent. */ +private val MONTH_INDEX_KEY = intPreferencesKey("split_month_index") +private val SELECTED_KEY = stringPreferencesKey("split_selected") + +/** What the month + day widget draws: the month widget's window, plus what its list needs. */ +sealed interface MonthDayData { + data object NeedsPermission : MonthDayData + data class Ready( + val source: MonthWidgetSource.Ready, + val options: AgendaWidgetOptions, + val is24Hour: Boolean, + val now: Instant, + ) : MonthDayData +} + +/** [loaded]'s month window with what the day list draws by. */ +internal suspend fun Context.loadMonthDay(loaded: Themed, options: AgendaWidgetOptions): Themed { + val ready = loaded.content as? MonthWidgetSource.Ready ?: return Themed(MonthDayData.NeedsPermission, loaded.appTheme) + val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first() + return Themed( + MonthDayData.Ready( + source = ready, + options = options, + is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)), + now = Clock.System.now(), + ), + loaded.appTheme, + ) +} + +/** + * Month + day (#441): a compact month grid beside the selected day's events, + * like Samsung Calendar's. A day in the grid selects it, starting on today; + * the list and its events open the app. The plain month widget keeps + * tap-opens-the-day (#386). + */ +class MonthDayWidget : GlanceAppWidget() { + + override val stateDefinition = PreferencesGlanceStateDefinition + + override val sizeMode = SizeMode.Exact + + override val previewSizeMode = previewSizeOf(WidgetKind.MONTH_DAY) + + override suspend fun providePreview(context: Context, widgetCategory: Int) = + provideSamplePreview(context, WidgetKind.MONTH_DAY) + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val stamp = refreshStamp(context, id) + val shown = context.widgetState(id)[MONTH_INDEX_KEY] ?: monthIndexOf(today(systemZone())) + val center = monthWindowCenter(shown) + val load: suspend (Int, Long) -> Themed = { c, s -> + context.loadMonthDay(context.loadCachedMonthSource(id, c, s), context.widgetLoadContext(id).options) + } + val widgetData = WidgetData(load(center, stamp), center, stamp) + provideContent { + val index = currentState(MONTH_INDEX_KEY) ?: monthIndexOf(today(systemZone())) + val currentCenter = monthWindowCenter(index) + val currentStamp = currentRefreshStamp() + val loaded = widgetData.current(currentCenter, currentStamp) { load(currentCenter, currentStamp) } + val selected = currentState(SELECTED_KEY)?.let(LocalDate::parse) + MonthDayWidgetContent(loaded.content, index, selected, currentState().widgetAppearance, loaded.appTheme) + } + } +} + +class MonthDayWidgetReceiver : CalendulaWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = MonthDayWidget() +} + +/** Selects the tapped day; the list redraws from what's loaded. */ +class SelectDayAction : ActionCallback { + override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { + val date = parameters[dateKey] ?: return + updateAppWidgetState(context, glanceId) { prefs -> + if (date == today(systemZone()).toString()) prefs.remove(SELECTED_KEY) else prefs[SELECTED_KEY] = date + } + MonthDayWidget().update(context.applicationContext, glanceId) + } + + companion object { + val dateKey = ActionParameters.Key("date") + } +} + +/** Pages the grid by the `delta` parameter; landing on the current month follows the date again. */ +class ShiftMonthDayAction : ActionCallback { + override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { + val delta = parameters[deltaKey] ?: 0 + val current = monthIndexOf(today(systemZone())) + updateAppWidgetState(context, glanceId) { prefs -> + val next = (prefs[MONTH_INDEX_KEY] ?: current) + delta + if (next == current) prefs.remove(MONTH_INDEX_KEY) else prefs[MONTH_INDEX_KEY] = next + } + MonthDayWidget().update(context.applicationContext, glanceId) + } + + companion object { + val deltaKey = ActionParameters.Key("delta") + } +} + +/** Back to this month with today selected. */ +class ResetMonthDayAction : ActionCallback { + override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { + updateAppWidgetState(context, glanceId) { prefs -> + prefs.remove(MONTH_INDEX_KEY) + prefs.remove(SELECTED_KEY) + } + MonthDayWidget().update(context.applicationContext, glanceId) + } +} + +/** The grid's days: whole weeks from [weekStart] covering [index]'s month. */ +internal fun monthGridDays(index: Int, weekStart: DayOfWeek): List { + val first = LocalDate(index / 12, index % 12 + 1, 1) + val start = weekOf(first, weekStart).start + val last = first.plus(1, DateTimeUnit.MONTH).plus(-1, DateTimeUnit.DAY) + val weeks = (start.daysUntilInclusive(last) + 6) / 7 + return List(weeks * 7) { start.plus(it, DateTimeUnit.DAY) } +} + +private fun LocalDate.daysUntilInclusive(other: LocalDate): Int = + (other.toEpochDays() - toEpochDays()).toInt() + 1 + +private val GridGap = 8.dp + +@Composable +internal fun MonthDayWidgetContent( + data: MonthDayData, + index: Int, + selected: LocalDate?, + appearance: WidgetAppearance, + appTheme: ThemeMode, + preview: Boolean = false, +) { + CalendulaWidgetRoot(appearance, appTheme) { + val context = LocalContext.current + when (data) { + MonthDayData.NeedsPermission -> WidgetMessage(context.getString(R.string.widget_needs_permission)) + is MonthDayData.Ready -> { + val source = data.source + val day = selected ?: source.today + val zone = systemZone() + val days = monthGridDays(index, source.weekStart) + val busy = groupAgendaDays(days.first(), days.last(), source.instances, zone).map { it.date }.toSet() + Column(modifier = GlanceModifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 6.dp)) { + Header(index, source.today) + Spacer(GlanceModifier.height(4.dp)) + Row(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) { + val gridWidth = (LocalSize.current.width - 16.dp - GridGap) * 0.52f + Box(modifier = GlanceModifier.width(gridWidth).fillMaxHeight()) { + Grid(days, index, source, day, busy, gridWidth) + } + Spacer(GlanceModifier.width(GridGap)) + Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight()) { + DayList(day, data, preview) + } + } + } + } + } + } +} + +@Composable +private fun Header(index: Int, today: LocalDate) { + val context = LocalContext.current + WidgetHeader( + title = formatCalendarTitle( + date = java.time.LocalDate.of(index / 12, index % 12 + 1, 1), + locale = widgetLocale(), + currentYear = today.year, + skeleton = "LLLL", + ), + titleAction = actionStartActivity(MainActivity.openViewIntent(context, CalendarView.Month)), + modifier = GlanceModifier.padding(bottom = 2.dp), + titleInset = 4.dp, + ) { + WidgetHeaderAction( + icon = ImageProvider(R.drawable.ic_widget_chevron_left), + contentDescription = context.getString(R.string.widget_prev_month), + action = actionRunCallback(actionParametersOf(ShiftMonthDayAction.deltaKey to -1)), + style = WidgetActionStyle.TONAL, + ) + WidgetHeaderActionGap() + WidgetHeaderTextAction( + text = today.day.toString(), + contentDescription = context.getString(R.string.widget_today), + action = actionRunCallback(), + style = WidgetActionStyle.TONAL, + ) + WidgetHeaderActionGap() + WidgetHeaderAction( + icon = ImageProvider(R.drawable.ic_widget_chevron_right), + contentDescription = context.getString(R.string.widget_next_month), + action = actionRunCallback(actionParametersOf(ShiftMonthDayAction.deltaKey to 1)), + style = WidgetActionStyle.TONAL, + ) + WidgetHeaderActionGap() + WidgetHeaderAction( + icon = ImageProvider(R.drawable.ic_widget_add), + contentDescription = context.getString(R.string.widget_new_event), + action = actionStartActivity(MainActivity.openCreateIntent(context, today)), + ) + } +} + +/** + * The month as numbers: selected day filled, today in bold primary, days + * with events dotted, days around the month faded. Three views a day keeps + * a six-week month well inside Glance's view ids (#372). + */ +@Composable +private fun Grid( + days: List, + index: Int, + source: MonthWidgetSource.Ready, + selected: LocalDate, + busy: Set, + width: Dp, +) { + val locale = widgetLocale() + val month = index % 12 + 1 + val backing = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) { + GlanceModifier.cornerRadius(14.dp).background(widgetCardColor()) + } else { + GlanceModifier + } + Column(modifier = backing.fillMaxSize().padding(2.dp)) { + Row(modifier = GlanceModifier.fillMaxWidth()) { + for (i in 0 until 7) { + Text( + text = java.time.DayOfWeek.of((source.weekStart.ordinal + i) % 7 + 1).getDisplayName(JavaTextStyle.NARROW, locale), + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 10.sp, textAlign = TextAlign.Center), + modifier = GlanceModifier.width((width - 4.dp) / 7), + ) + } + } + days.chunked(7).forEach { week -> + Row(modifier = GlanceModifier.fillMaxWidth().defaultWeight(), verticalAlignment = Alignment.CenterVertically) { + week.forEach { date -> + DayCell(date, date.month.ordinal + 1 == month, date == source.today, date == selected, date in busy, (width - 4.dp) / 7) + } + } + } + } +} + +@Composable +private fun DayCell(date: LocalDate, inMonth: Boolean, isToday: Boolean, isSelected: Boolean, hasEvents: Boolean, width: Dp) { + val ink = when { + isSelected -> GlanceTheme.colors.onPrimary + isToday -> GlanceTheme.colors.primary + inMonth -> GlanceTheme.colors.onSurface + else -> GlanceTheme.colors.outline + } + // Always set: the host reapplies onto reused views, so a dropped background would linger. + val fill = if (isSelected) GlanceTheme.colors.primary else ColorProvider(Color.Transparent) + Column( + modifier = GlanceModifier.cornerRadius(10.dp).background(fill) + .width(width) + .fillMaxHeight() + .clickable(actionRunCallback(actionParametersOf(SelectDayAction.dateKey to date.toString()))), + horizontalAlignment = Alignment.CenterHorizontally, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = date.day.toString(), + maxLines = 1, + style = TextStyle( + color = ink, + fontSize = 11.sp, + fontWeight = if (isToday || isSelected) FontWeight.Bold else FontWeight.Normal, + textAlign = TextAlign.Center, + ), + ) + Text( + text = if (hasEvents) "•" else " ", + maxLines = 1, + style = TextStyle(color = if (isSelected) ink else GlanceTheme.colors.primary, fontSize = 7.sp, textAlign = TextAlign.Center), + ) + } +} + +@Composable +private fun DayList(day: LocalDate, data: MonthDayData.Ready, preview: Boolean) { + val context = LocalContext.current + val source = data.source + val zone = systemZone() + val events = groupAgendaDays(day, day, source.instances, zone).firstOrNull()?.events.orEmpty() + .let { all -> if (data.options.pastDisplay == PastEventDisplay.HIDE) all.filterNot { it.hasEnded(data.now) } else all } + val metrics = compactRowMetrics(data.options.textSize, context.resources.configuration.fontScale) + val openDay = actionStartActivity(MainActivity.openDateIntent(context, day, CalendarView.Month)) + Column(modifier = GlanceModifier.fillMaxSize()) { + val date = localizedDateFormatter(widgetLocale(), "EEEdMMM").format(day.toJavaLocalDate()) + val label = when (day) { + source.today, source.today.plus(1, DateTimeUnit.DAY) -> "${relativeDayLabel(context, day, source.today)} · $date" + else -> date + } + Text( + text = label, + maxLines = 1, + style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = metrics.time, fontWeight = FontWeight.Medium), + modifier = GlanceModifier + .padding(bottom = 4.dp) + .wallpaperBacking(radius = 8.dp) + .padding(horizontal = 6.dp, vertical = 2.dp) + .clickable(openDay), + ) + if (events.isEmpty()) { + Box( + modifier = GlanceModifier.fillMaxSize().cornerRadius(14.dp).background(widgetCardColor()).clickable(openDay), + contentAlignment = Alignment.Center, + ) { + Text( + text = context.getString(R.string.widget_day_empty), + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.time, textAlign = TextAlign.Center), + modifier = GlanceModifier.padding(8.dp), + ) + } + } else { + val row: @Composable (Int) -> Unit = { i -> + val event = events[i] + WidgetEventRow( + event = event, + secondary = widgetTimeLabel(context, event, day, data.is24Hour), + soften = source.soften, + metrics = metrics, + position = positionOf(i, events.size), + source = CalendarView.Month, + dimmed = data.options.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now), + ) + } + if (preview) { + Column(modifier = GlanceModifier.fillMaxSize()) { repeat(minOf(events.size, 8)) { row(it) } } + } else { + LazyColumn(modifier = GlanceModifier.fillMaxSize()) { items(events.size) { row(it) } } + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/today/TodayWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/today/TodayWidget.kt new file mode 100644 index 0000000..bd6e757 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/today/TodayWidget.kt @@ -0,0 +1,252 @@ +package de.jeanlucmakiola.calendula.widget.today + +import de.jeanlucmakiola.calendula.widget.WidgetKind +import de.jeanlucmakiola.calendula.widget.previewSizeOf +import de.jeanlucmakiola.calendula.widget.provideSamplePreview +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.datastore.preferences.core.Preferences +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.lazy.LazyColumn +import androidx.glance.appwidget.lazy.items +import androidx.glance.appwidget.provideContent +import androidx.glance.background +import androidx.glance.currentState +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxHeight +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.width +import androidx.glance.semantics.contentDescription +import androidx.glance.semantics.semantics +import androidx.glance.state.PreferencesGlanceStateDefinition +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextAlign +import androidx.glance.text.TextStyle +import de.jeanlucmakiola.calendula.MainActivity +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.hasEnded +import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver +import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot +import de.jeanlucmakiola.calendula.widget.DaysWidgetData +import de.jeanlucmakiola.calendula.widget.EventRowMetrics +import de.jeanlucmakiola.calendula.widget.WidgetAppearance +import de.jeanlucmakiola.calendula.widget.WidgetEventRow +import de.jeanlucmakiola.calendula.widget.WidgetLoadContext +import de.jeanlucmakiola.calendula.widget.compactRowMetrics +import de.jeanlucmakiola.calendula.widget.loadDays +import de.jeanlucmakiola.calendula.widget.relativeDayLabel +import de.jeanlucmakiola.calendula.widget.widgetAppearance +import de.jeanlucmakiola.calendula.widget.widgetLocale +import de.jeanlucmakiola.calendula.widget.widgetLoadContext +import de.jeanlucmakiola.calendula.widget.widgetTimeLabel +import de.jeanlucmakiola.floret.components.positionOf +import de.jeanlucmakiola.floret.glance.LocalWidgetBackground +import de.jeanlucmakiola.floret.glance.WidgetBackground +import de.jeanlucmakiola.floret.glance.WidgetData +import de.jeanlucmakiola.floret.glance.WidgetMessage +import de.jeanlucmakiola.floret.glance.currentRefreshStamp +import de.jeanlucmakiola.floret.glance.refreshStamp +import de.jeanlucmakiola.floret.glance.wallpaperBacking +import de.jeanlucmakiola.floret.locale.localizedDateFormatter +import de.jeanlucmakiola.floret.prefs.ThemeMode +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.plus +import kotlinx.datetime.toJavaDayOfWeek +import kotlinx.datetime.toJavaLocalDate +import java.time.format.TextStyle as JavaTextStyle + +/** + * Today list (#438): only today's events beside today's date pill. Once + * they're over, or with none at all, it says so and shows tomorrow's first + * one or two events in a quieter style. + */ +class TodayWidget : GlanceAppWidget() { + + override val stateDefinition = PreferencesGlanceStateDefinition + + // Exact: whether one or two of tomorrow's events fit follows the height. + override val sizeMode = SizeMode.Exact + + override val previewSizeMode = previewSizeOf(WidgetKind.TODAY) + + override suspend fun providePreview(context: Context, widgetCategory: Int) = + provideSamplePreview(context, WidgetKind.TODAY) + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val widgetData = WidgetData(context.loadTodayList(context.widgetLoadContext(id)), refreshStamp(context, id)) + provideContent { + val loaded = widgetData.current(currentRefreshStamp()) { context.loadTodayList(context.widgetLoadContext(id)) } + TodayWidgetContent(loaded.content, currentState().widgetAppearance, loaded.appTheme) + } + } +} + +class TodayWidgetReceiver : CalendulaWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = TodayWidget() +} + +internal suspend fun Context.loadTodayList(load: WidgetLoadContext) = loadDays(load) { today, _ -> + today..today.plus(1, DateTimeUnit.DAY) +} + +private val PillWidth = 44.dp +private val RowGap = 2.dp + +/** What the list side shows: today's events, or the look ahead once none are left. */ +internal sealed interface TodayRows { + data class Today(val events: List) : TodayRows + data class NothingLeft(val tomorrow: List) : TodayRows +} + +internal fun todayRows(data: DaysWidgetData.Ready): TodayRows { + val today = data.eventsOn(data.today) + if (today.none { !it.hasEnded(data.now) }) { + return TodayRows.NothingLeft(data.eventsOn(data.today.plus(1, DateTimeUnit.DAY))) + } + val shown = if (data.options.pastDisplay == PastEventDisplay.HIDE) today.filterNot { it.hasEnded(data.now) } else today + return TodayRows.Today(shown) +} + +/** The whole widget, shared with its config preview, which draws its list as a plain column. */ +@Composable +internal fun TodayWidgetContent( + data: DaysWidgetData, + appearance: WidgetAppearance, + appTheme: ThemeMode, + preview: Boolean = false, +) { + CalendulaWidgetRoot(appearance, appTheme) { + val context = LocalContext.current + when (data) { + DaysWidgetData.NeedsPermission -> WidgetMessage(context.getString(R.string.widget_needs_permission)) + is DaysWidgetData.Ready -> Row(modifier = GlanceModifier.fillMaxSize().padding(6.dp)) { + TodayPill(data.today) + Spacer(GlanceModifier.width(6.dp)) + val metrics = compactRowMetrics(data.options.textSize, context.resources.configuration.fontScale) + Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight()) { + when (val rows = todayRows(data)) { + is TodayRows.Today -> TodayList(rows.events, data, metrics, preview) + is TodayRows.NothingLeft -> NothingLeft(rows.tomorrow, data, metrics) + } + } + } + } + } +} + +@Composable +private fun TodayList(events: List, data: DaysWidgetData.Ready, metrics: EventRowMetrics, preview: Boolean) { + val context = LocalContext.current + val row: @Composable (Int) -> Unit = { index -> + val event = events[index] + WidgetEventRow( + event = event, + secondary = widgetTimeLabel(context, event, data.today, data.is24Hour), + soften = data.soften, + metrics = metrics, + position = positionOf(index, events.size), + source = CalendarView.Day, + dimmed = data.options.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now), + ) + } + if (preview) { + val fits = ((LocalSize.current.height - 12.dp + RowGap) / (metrics.rowHeight + RowGap)).toInt().coerceAtLeast(1) + Column(modifier = GlanceModifier.fillMaxSize()) { repeat(minOf(events.size, fits)) { row(it) } } + } else { + LazyColumn(modifier = GlanceModifier.fillMaxSize()) { items(events.size) { row(it) } } + } +} + +/** "Nothing left today", then as many of tomorrow's first two events as the height holds. */ +@Composable +private fun NothingLeft(tomorrow: List, data: DaysWidgetData.Ready, metrics: EventRowMetrics) { + val context = LocalContext.current + val fontScale = context.resources.configuration.fontScale + val lineHeight = 20.dp * fontScale + val room = LocalSize.current.height - 12.dp - lineHeight + val fits = ((room + RowGap) / (metrics.rowHeight + RowGap)).toInt().coerceIn(0, 2) + val ahead = tomorrow.take(fits) + val backing = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) { + GlanceModifier.wallpaperBacking(radius = 8.dp).padding(horizontal = 8.dp) + } else { + GlanceModifier.padding(horizontal = 4.dp) + } + Column(modifier = GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) { + Text( + text = context.getString(R.string.widget_nothing_left_today), + maxLines = 1, + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.title, fontWeight = FontWeight.Medium), + modifier = backing.height(lineHeight), + ) + val tomorrowDate = data.today.plus(1, DateTimeUnit.DAY) + val tomorrowLabel = relativeDayLabel(context, tomorrowDate, data.today) + ahead.forEachIndexed { index, event -> + WidgetEventRow( + event = event, + secondary = "$tomorrowLabel · ${widgetTimeLabel(context, event, tomorrowDate, data.is24Hour)}", + soften = data.soften, + metrics = metrics, + position = positionOf(index, ahead.size), + source = CalendarView.Day, + quiet = true, + ) + } + } +} + +/** Today's weekday and number, filled like the agenda's today pill; opens today. */ +@Composable +private fun TodayPill(today: LocalDate) { + val context = LocalContext.current + val locale = widgetLocale() + Column( + modifier = GlanceModifier + .width(PillWidth) + .fillMaxHeight() + .cornerRadius(PillWidth / 2) + .background(GlanceTheme.colors.primary) + .clickable(actionStartActivity(MainActivity.openDateIntent(context, today, CalendarView.Day))) + .semantics { contentDescription = localizedDateFormatter(locale, "EEEEdMMMM").format(today.toJavaLocalDate()) }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = today.dayOfWeek.toJavaDayOfWeek().getDisplayName(JavaTextStyle.SHORT, locale), + maxLines = 1, + style = TextStyle(color = GlanceTheme.colors.onPrimary, fontSize = 11.sp, textAlign = TextAlign.Center), + ) + Text( + text = today.day.toString(), + maxLines = 1, + style = TextStyle( + color = GlanceTheme.colors.onPrimary, + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + ), + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/upcoming/UpcomingWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/upcoming/UpcomingWidget.kt new file mode 100644 index 0000000..1f4ddd7 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/upcoming/UpcomingWidget.kt @@ -0,0 +1,188 @@ +package de.jeanlucmakiola.calendula.widget.upcoming + +import de.jeanlucmakiola.calendula.widget.WidgetKind +import de.jeanlucmakiola.calendula.widget.previewSizeOf +import de.jeanlucmakiola.calendula.widget.provideSamplePreview +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.datastore.preferences.core.Preferences +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.provideContent +import androidx.glance.currentState +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.width +import androidx.glance.state.PreferencesGlanceStateDefinition +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import de.jeanlucmakiola.calendula.MainActivity +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.hasEnded +import de.jeanlucmakiola.calendula.ui.agenda.dayCount +import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver +import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot +import de.jeanlucmakiola.calendula.widget.DaysWidgetData +import de.jeanlucmakiola.calendula.widget.WidgetAppearance +import de.jeanlucmakiola.calendula.widget.WidgetEventRow +import de.jeanlucmakiola.calendula.widget.WidgetLoadContext +import de.jeanlucmakiola.calendula.widget.compactRowMetrics +import de.jeanlucmakiola.calendula.widget.loadDays +import de.jeanlucmakiola.calendula.widget.relativeDayLabel +import de.jeanlucmakiola.calendula.widget.widgetAppearance +import de.jeanlucmakiola.calendula.widget.widgetLocale +import de.jeanlucmakiola.calendula.widget.widgetLoadContext +import de.jeanlucmakiola.calendula.widget.widgetTimeLabel +import de.jeanlucmakiola.floret.components.positionOf +import de.jeanlucmakiola.floret.glance.LocalWidgetBackground +import de.jeanlucmakiola.floret.glance.WidgetBackground +import de.jeanlucmakiola.floret.glance.WidgetData +import de.jeanlucmakiola.floret.glance.WidgetMessage +import de.jeanlucmakiola.floret.glance.currentRefreshStamp +import de.jeanlucmakiola.floret.glance.refreshStamp +import de.jeanlucmakiola.floret.glance.wallpaperBacking +import de.jeanlucmakiola.floret.prefs.ThemeMode +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.plus +import kotlinx.datetime.toJavaDayOfWeek +import java.time.format.TextStyle as JavaTextStyle + +/** + * 2×2 upcoming (#437): today's date on top, then the next events across the + * widget's range in compact rows, as many as its height holds. The date opens + * the agenda, an event opens itself. + */ +class UpcomingWidget : GlanceAppWidget() { + + override val stateDefinition = PreferencesGlanceStateDefinition + + // Exact: how many rows show follows the height. + override val sizeMode = SizeMode.Exact + + override val previewSizeMode = previewSizeOf(WidgetKind.UPCOMING) + + override suspend fun providePreview(context: Context, widgetCategory: Int) = + provideSamplePreview(context, WidgetKind.UPCOMING) + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val widgetData = WidgetData(context.loadUpcoming(context.widgetLoadContext(id)), refreshStamp(context, id)) + provideContent { + val loaded = widgetData.current(currentRefreshStamp()) { context.loadUpcoming(context.widgetLoadContext(id)) } + UpcomingWidgetContent(loaded.content, currentState().widgetAppearance, loaded.appTheme) + } + } +} + +class UpcomingWidgetReceiver : CalendulaWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = UpcomingWidget() +} + +internal suspend fun Context.loadUpcoming(load: WidgetLoadContext) = loadDays(load) { today, weekStart -> + today..today.plus(load.options.range.dayCount(today, weekStart) - 1, DateTimeUnit.DAY) +} + +/** Most rows a tall widget lists; past this it is an agenda, which is its own widget. */ +private const val MAX_ROWS = 8 + +private val RowGap = 2.dp + +/** Every event still to come or running, each once, on the first day it shows. */ +internal fun upcomingEvents(data: DaysWidgetData.Ready): List> = + data.days + .flatMap { day -> day.events.map { day.date to it } } + .filterNot { (_, event) -> event.hasEnded(data.now) } + .distinctBy { (_, event) -> event.instanceId to event.start } + +@Composable +internal fun UpcomingWidgetContent(data: DaysWidgetData, appearance: WidgetAppearance, appTheme: ThemeMode) { + CalendulaWidgetRoot(appearance, appTheme) { + val context = LocalContext.current + when (data) { + DaysWidgetData.NeedsPermission -> WidgetMessage(context.getString(R.string.widget_needs_permission)) + is DaysWidgetData.Ready -> { + val fontScale = context.resources.configuration.fontScale + val metrics = compactRowMetrics(data.options.textSize, fontScale) + val headerHeight = 44.dp * fontScale + val room = LocalSize.current.height - 16.dp - headerHeight + val fits = ((room + RowGap) / (metrics.rowHeight + RowGap)).toInt().coerceIn(1, MAX_ROWS) + val events = upcomingEvents(data).take(fits) + Column(modifier = GlanceModifier.fillMaxSize().padding(8.dp)) { + DateHeader(data.today, headerHeight) + if (events.isEmpty()) { + WidgetMessage(context.getString(R.string.widget_upcoming_empty)) + } else { + events.forEachIndexed { index, (day, event) -> + val time = widgetTimeLabel(context, event, day, data.is24Hour) + WidgetEventRow( + event = event, + secondary = if (day == data.today) time else "${relativeDayLabel(context, day, data.today)} · $time", + soften = data.soften, + metrics = metrics, + position = positionOf(index, events.size), + source = CalendarView.Agenda, + ) + } + } + } + } + } + } +} + +/** Today's number beside its weekday and month; opens the agenda. */ +@Composable +private fun DateHeader(today: LocalDate, height: Dp) { + val context = LocalContext.current + val locale = widgetLocale() + val backing = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) { + GlanceModifier.wallpaperBacking(radius = 12.dp).padding(horizontal = 8.dp) + } else { + GlanceModifier.padding(horizontal = 4.dp) + } + Box(modifier = GlanceModifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) { + Row( + modifier = backing.clickable(actionStartActivity(MainActivity.openViewIntent(context, CalendarView.Agenda))), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = today.day.toString(), + maxLines = 1, + style = TextStyle(color = GlanceTheme.colors.primary, fontSize = 28.sp, fontWeight = FontWeight.Bold), + ) + Spacer(GlanceModifier.width(8.dp)) + Column { + Text( + text = today.dayOfWeek.toJavaDayOfWeek().getDisplayName(JavaTextStyle.FULL, locale), + maxLines = 1, + style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 13.sp, fontWeight = FontWeight.Medium), + ) + Text( + text = java.time.Month.of(today.month.ordinal + 1).getDisplayName(JavaTextStyle.FULL_STANDALONE, locale), + maxLines = 1, + style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp), + ) + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/week/WeekStripWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/week/WeekStripWidget.kt new file mode 100644 index 0000000..eac2150 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/week/WeekStripWidget.kt @@ -0,0 +1,196 @@ +package de.jeanlucmakiola.calendula.widget.week + +import de.jeanlucmakiola.calendula.widget.WidgetKind +import de.jeanlucmakiola.calendula.widget.previewSizeOf +import de.jeanlucmakiola.calendula.widget.provideSamplePreview +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.datastore.preferences.core.Preferences +import androidx.glance.ColorFilter +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.cornerRadius +import androidx.glance.appwidget.provideContent +import androidx.glance.background +import androidx.glance.currentState +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxHeight +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.semantics.contentDescription +import androidx.glance.semantics.semantics +import androidx.glance.state.PreferencesGlanceStateDefinition +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextAlign +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider +import de.jeanlucmakiola.calendula.MainActivity +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.eventAccent +import de.jeanlucmakiola.calendula.ui.common.eventFill +import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver +import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot +import de.jeanlucmakiola.calendula.widget.DaysWidgetData +import de.jeanlucmakiola.calendula.widget.WidgetAppearance +import de.jeanlucmakiola.calendula.widget.loadDays +import de.jeanlucmakiola.calendula.widget.weekOf +import de.jeanlucmakiola.calendula.widget.widgetAppearance +import de.jeanlucmakiola.calendula.widget.widgetLocale +import de.jeanlucmakiola.calendula.widget.widgetCardColor +import de.jeanlucmakiola.calendula.widget.widgetLoadContext +import de.jeanlucmakiola.floret.glance.WidgetData +import de.jeanlucmakiola.floret.glance.WidgetMessage +import de.jeanlucmakiola.floret.glance.currentRefreshStamp +import de.jeanlucmakiola.floret.glance.refreshStamp +import de.jeanlucmakiola.floret.glance.themedColor +import de.jeanlucmakiola.floret.locale.localizedDateFormatter +import de.jeanlucmakiola.floret.prefs.ThemeMode +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.plus +import kotlinx.datetime.toJavaDayOfWeek +import kotlinx.datetime.toJavaLocalDate +import java.time.format.TextStyle as JavaTextStyle + +/** + * 4×1 week strip (#436): the current week's seven days from the app's week + * start, today filled, a dot per event colour under each day. A day opens + * its day view. + */ +class WeekStripWidget : GlanceAppWidget() { + + override val stateDefinition = PreferencesGlanceStateDefinition + + override val sizeMode = SizeMode.Single + + override val previewSizeMode = previewSizeOf(WidgetKind.WEEK_STRIP) + + override suspend fun providePreview(context: Context, widgetCategory: Int) = + provideSamplePreview(context, WidgetKind.WEEK_STRIP) + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val widgetData = WidgetData(context.loadWeek(id), refreshStamp(context, id)) + provideContent { + val loaded = widgetData.current(currentRefreshStamp()) { context.loadWeek(id) } + WeekStripWidgetContent(loaded.content, currentState().widgetAppearance, loaded.appTheme) + } + } +} + +class WeekStripWidgetReceiver : CalendulaWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = WeekStripWidget() +} + +private suspend fun Context.loadWeek(id: GlanceId) = loadDays(widgetLoadContext(id), ::weekOf) + +/** Most dots a day shows; more colours than this read as noise at this size. */ +private const val MAX_DOTS = 3 + +private val DayGap = 4.dp +private val DotSize = 5.dp + +@Composable +internal fun WeekStripWidgetContent(data: DaysWidgetData, appearance: WidgetAppearance, appTheme: ThemeMode) { + CalendulaWidgetRoot(appearance, appTheme) { + when (data) { + DaysWidgetData.NeedsPermission -> + WidgetMessage(LocalContext.current.getString(R.string.widget_needs_permission)) + is DaysWidgetData.Ready -> Row( + modifier = GlanceModifier.fillMaxSize().padding(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + for (i in 0 until 7) { + if (i > 0) Spacer(GlanceModifier.width(DayGap)) + Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight()) { + DayTile(data.firstDay.plus(i, DateTimeUnit.DAY), data) + } + } + } + } + } +} + +@Composable +private fun DayTile(date: LocalDate, data: DaysWidgetData.Ready) { + val context = LocalContext.current + val isToday = date == data.today + val container = if (isToday) GlanceTheme.colors.primary else widgetCardColor() + val ink = when { + isToday -> GlanceTheme.colors.onPrimary + date < data.today -> GlanceTheme.colors.onSurfaceVariant + else -> GlanceTheme.colors.onSurface + } + val locale = widgetLocale() + val colors = data.eventsOn(date).map { it.color }.distinct() + val label = localizedDateFormatter(locale, "EEEEdMMMM").format(date.toJavaLocalDate()) + Column( + modifier = GlanceModifier + .fillMaxSize() + .cornerRadius(14.dp) + .background(container) + .clickable(actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Week))) + .semantics { + contentDescription = if (colors.isEmpty()) label else context.resources.getQuantityString( + R.plurals.widget_day_with_events, data.eventsOn(date).size, label, data.eventsOn(date).size, + ) + }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = date.dayOfWeek.toJavaDayOfWeek().getDisplayName(JavaTextStyle.SHORT, locale), + maxLines = 1, + style = TextStyle(color = ink, fontSize = 11.sp, textAlign = TextAlign.Center), + ) + Text( + text = date.day.toString(), + maxLines = 1, + style = TextStyle(color = ink, fontSize = 17.sp, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center), + ) + Spacer(GlanceModifier.height(3.dp)) + // A dot row is always drawn, so the numbers line up across days. + Row(modifier = GlanceModifier.height(DotSize)) { + colors.take(MAX_DOTS).forEachIndexed { index, color -> + if (index > 0) Spacer(GlanceModifier.width(3.dp)) + Dot(dotColor(color, isToday, data.soften)) + } + } + } +} + +/** A mark on the tile: the accent tone on a card, the lighter fill tone on today's primary. */ +@Composable +private fun dotColor(color: Int, onPrimary: Boolean, soften: Boolean): ColorProvider = if (onPrimary) { + themedColor(day = eventFill(color, dark = false, soften), night = eventFill(color, dark = true, soften)) +} else { + themedColor(day = eventAccent(color, dark = false, soften), night = eventAccent(color, dark = true, soften)) +} + +@Composable +private fun Dot(color: ColorProvider) { + Image( + provider = ImageProvider(R.drawable.widget_dot), + contentDescription = null, + colorFilter = ColorFilter.tint(color), + modifier = GlanceModifier.size(DotSize), + ) +} diff --git a/app/src/main/res/drawable/ic_widget_refresh.xml b/app/src/main/res/drawable/ic_widget_refresh.xml deleted file mode 100644 index ee48912..0000000 --- a/app/src/main/res/drawable/ic_widget_refresh.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_widget_today.xml b/app/src/main/res/drawable/ic_widget_today.xml deleted file mode 100644 index 075ba88..0000000 --- a/app/src/main/res/drawable/ic_widget_today.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/preview_stripe.xml b/app/src/main/res/drawable/preview_stripe.xml index 9351a43..f1be56b 100644 --- a/app/src/main/res/drawable/preview_stripe.xml +++ b/app/src/main/res/drawable/preview_stripe.xml @@ -1,6 +1,6 @@ - + diff --git a/app/src/main/res/drawable/preview_widget_cell_bg.xml b/app/src/main/res/drawable/preview_widget_cell_bg.xml new file mode 100644 index 0000000..2d8aea5 --- /dev/null +++ b/app/src/main/res/drawable/preview_widget_cell_bg.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/preview_widget_event_bg.xml b/app/src/main/res/drawable/preview_widget_event_bg.xml new file mode 100644 index 0000000..0660e55 --- /dev/null +++ b/app/src/main/res/drawable/preview_widget_event_bg.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/preview_widget_outside_bg.xml b/app/src/main/res/drawable/preview_widget_outside_bg.xml new file mode 100644 index 0000000..8882d92 --- /dev/null +++ b/app/src/main/res/drawable/preview_widget_outside_bg.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/preview_widget_today_bg.xml b/app/src/main/res/drawable/preview_widget_today_bg.xml new file mode 100644 index 0000000..6fe3b70 --- /dev/null +++ b/app/src/main/res/drawable/preview_widget_today_bg.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_dot.xml b/app/src/main/res/drawable/widget_dot.xml new file mode 100644 index 0000000..3cd90b3 --- /dev/null +++ b/app/src/main/res/drawable/widget_dot.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/widget_month_bar_bg.xml b/app/src/main/res/drawable/widget_month_bar_bg.xml new file mode 100644 index 0000000..eb18a82 --- /dev/null +++ b/app/src/main/res/drawable/widget_month_bar_bg.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_month_cell_bg.xml b/app/src/main/res/drawable/widget_month_cell_bg.xml new file mode 100644 index 0000000..34615d9 --- /dev/null +++ b/app/src/main/res/drawable/widget_month_cell_bg.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_month_today_bg.xml b/app/src/main/res/drawable/widget_month_today_bg.xml new file mode 100644 index 0000000..3626003 --- /dev/null +++ b/app/src/main/res/drawable/widget_month_today_bg.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/layout/widget_month_bar_1.xml b/app/src/main/res/layout/widget_month_bar_1.xml new file mode 100644 index 0000000..38b7c5a --- /dev/null +++ b/app/src/main/res/layout/widget_month_bar_1.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/layout/widget_month_bar_2.xml b/app/src/main/res/layout/widget_month_bar_2.xml new file mode 100644 index 0000000..5310c85 --- /dev/null +++ b/app/src/main/res/layout/widget_month_bar_2.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/layout/widget_month_bar_3.xml b/app/src/main/res/layout/widget_month_bar_3.xml new file mode 100644 index 0000000..75eb3c3 --- /dev/null +++ b/app/src/main/res/layout/widget_month_bar_3.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/layout/widget_month_bar_4.xml b/app/src/main/res/layout/widget_month_bar_4.xml new file mode 100644 index 0000000..bb6557e --- /dev/null +++ b/app/src/main/res/layout/widget_month_bar_4.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/layout/widget_month_bar_5.xml b/app/src/main/res/layout/widget_month_bar_5.xml new file mode 100644 index 0000000..6cd75a8 --- /dev/null +++ b/app/src/main/res/layout/widget_month_bar_5.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/layout/widget_month_bar_6.xml b/app/src/main/res/layout/widget_month_bar_6.xml new file mode 100644 index 0000000..e0cf5f2 --- /dev/null +++ b/app/src/main/res/layout/widget_month_bar_6.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/layout/widget_month_bar_7.xml b/app/src/main/res/layout/widget_month_bar_7.xml new file mode 100644 index 0000000..17d7b32 --- /dev/null +++ b/app/src/main/res/layout/widget_month_bar_7.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/layout/widget_month_gap_1.xml b/app/src/main/res/layout/widget_month_gap_1.xml new file mode 100644 index 0000000..6d18a12 --- /dev/null +++ b/app/src/main/res/layout/widget_month_gap_1.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/layout/widget_month_gap_2.xml b/app/src/main/res/layout/widget_month_gap_2.xml new file mode 100644 index 0000000..5d6f690 --- /dev/null +++ b/app/src/main/res/layout/widget_month_gap_2.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/layout/widget_month_gap_3.xml b/app/src/main/res/layout/widget_month_gap_3.xml new file mode 100644 index 0000000..d73cef0 --- /dev/null +++ b/app/src/main/res/layout/widget_month_gap_3.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/layout/widget_month_gap_4.xml b/app/src/main/res/layout/widget_month_gap_4.xml new file mode 100644 index 0000000..b0ddf81 --- /dev/null +++ b/app/src/main/res/layout/widget_month_gap_4.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/layout/widget_month_gap_5.xml b/app/src/main/res/layout/widget_month_gap_5.xml new file mode 100644 index 0000000..d36a2aa --- /dev/null +++ b/app/src/main/res/layout/widget_month_gap_5.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/layout/widget_month_gap_6.xml b/app/src/main/res/layout/widget_month_gap_6.xml new file mode 100644 index 0000000..6da1869 --- /dev/null +++ b/app/src/main/res/layout/widget_month_gap_6.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/layout/widget_month_gap_7.xml b/app/src/main/res/layout/widget_month_gap_7.xml new file mode 100644 index 0000000..6ba0099 --- /dev/null +++ b/app/src/main/res/layout/widget_month_gap_7.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/layout/widget_month_grid.xml b/app/src/main/res/layout/widget_month_grid.xml new file mode 100644 index 0000000..bdf0ebf --- /dev/null +++ b/app/src/main/res/layout/widget_month_grid.xml @@ -0,0 +1,6 @@ + + diff --git a/app/src/main/res/layout/widget_month_lane.xml b/app/src/main/res/layout/widget_month_lane.xml new file mode 100644 index 0000000..57a8df1 --- /dev/null +++ b/app/src/main/res/layout/widget_month_lane.xml @@ -0,0 +1,8 @@ + + diff --git a/app/src/main/res/layout/widget_month_more.xml b/app/src/main/res/layout/widget_month_more.xml new file mode 100644 index 0000000..63f9b17 --- /dev/null +++ b/app/src/main/res/layout/widget_month_more.xml @@ -0,0 +1,11 @@ + + diff --git a/app/src/main/res/layout/widget_month_week.xml b/app/src/main/res/layout/widget_month_week.xml new file mode 100644 index 0000000..60942ab --- /dev/null +++ b/app/src/main/res/layout/widget_month_week.xml @@ -0,0 +1,360 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_preview_agenda.xml b/app/src/main/res/layout/widget_preview_agenda.xml index 75721fe..aceb98d 100644 --- a/app/src/main/res/layout/widget_preview_agenda.xml +++ b/app/src/main/res/layout/widget_preview_agenda.xml @@ -1,124 +1,186 @@ - + - - - - + android:orientation="vertical" + android:padding="8dp" + tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessParent"> - + android:gravity="center_vertical" + android:orientation="horizontal"> + + + + + + + + android:layout_marginTop="0dp" + android:background="@drawable/preview_widget_event_bg" + android:backgroundTint="@color/widget_preview_event_blue" + android:orientation="vertical" + android:paddingHorizontal="10dp" + android:paddingVertical="3dp"> + android:ellipsize="end" + android:maxLines="1" + android:text="@string/settings_past_events_sample_morning" + android:textColor="@color/widget_preview_on_event_light" + android:textSize="12sp" + android:textStyle="bold" /> + android:ellipsize="end" + android:maxLines="1" + android:text="9:30 – 10:00" + android:textColor="@color/widget_preview_on_event_light" + android:textSize="11sp" /> + + + + + + + + + - + android:layout_marginTop="6dp" + android:orientation="horizontal"> + + + android:layout_marginTop="0dp" + android:background="@drawable/preview_widget_event_bg" + android:backgroundTint="@color/widget_preview_event_orange" + android:orientation="vertical" + android:paddingHorizontal="10dp" + android:paddingVertical="3dp"> + android:ellipsize="end" + android:maxLines="1" + android:text="@string/widget_preview_sample_trip" + android:textColor="@color/widget_preview_on_event_light" + android:textSize="12sp" + android:textStyle="bold" /> + android:ellipsize="end" + android:maxLines="1" + android:text="@string/event_detail_all_day" + android:textColor="@color/widget_preview_on_event_light" + android:textSize="11sp" /> - - - - - - - diff --git a/app/src/main/res/layout/widget_preview_countdown.xml b/app/src/main/res/layout/widget_preview_countdown.xml new file mode 100644 index 0000000..966dbc3 --- /dev/null +++ b/app/src/main/res/layout/widget_preview_countdown.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_preview_date.xml b/app/src/main/res/layout/widget_preview_date.xml new file mode 100644 index 0000000..a5d8409 --- /dev/null +++ b/app/src/main/res/layout/widget_preview_date.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_preview_month.xml b/app/src/main/res/layout/widget_preview_month.xml index 90c2d0c..910375a 100644 --- a/app/src/main/res/layout/widget_preview_month.xml +++ b/app/src/main/res/layout/widget_preview_month.xml @@ -1,114 +1,776 @@ - + + android:orientation="vertical" + android:padding="8dp" + tools:ignore="HardcodedText,NestedWeights,DisableBaselineAlignment,SmallSp,TooManyViews,UselessParent"> - - + android:gravity="center_vertical" + android:orientation="horizontal"> - + + + + - - - - - - - - + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_preview_month_day.xml b/app/src/main/res/layout/widget_preview_month_day.xml new file mode 100644 index 0000000..451a6cd --- /dev/null +++ b/app/src/main/res/layout/widget_preview_month_day.xml @@ -0,0 +1,518 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_preview_quick_add.xml b/app/src/main/res/layout/widget_preview_quick_add.xml new file mode 100644 index 0000000..a896785 --- /dev/null +++ b/app/src/main/res/layout/widget_preview_quick_add.xml @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/app/src/main/res/layout/widget_preview_today.xml b/app/src/main/res/layout/widget_preview_today.xml new file mode 100644 index 0000000..61877fa --- /dev/null +++ b/app/src/main/res/layout/widget_preview_today.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_preview_upcoming.xml b/app/src/main/res/layout/widget_preview_upcoming.xml new file mode 100644 index 0000000..b714508 --- /dev/null +++ b/app/src/main/res/layout/widget_preview_upcoming.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_preview_week_strip.xml b/app/src/main/res/layout/widget_preview_week_strip.xml new file mode 100644 index 0000000..e2a0717 --- /dev/null +++ b/app/src/main/res/layout/widget_preview_week_strip.xml @@ -0,0 +1,202 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-night-v31/widget_preview_colors.xml b/app/src/main/res/values-night-v31/widget_preview_colors.xml index 26162c8..f987341 100644 --- a/app/src/main/res/values-night-v31/widget_preview_colors.xml +++ b/app/src/main/res/values-night-v31/widget_preview_colors.xml @@ -1,6 +1,8 @@ - @android:color/system_neutral1_900 + @android:color/system_accent2_700 + @android:color/system_accent2_900 + @android:color/system_neutral2_600 @android:color/system_neutral1_50 @android:color/system_neutral2_200 @android:color/system_accent1_200 diff --git a/app/src/main/res/values-night/widget_preview_colors.xml b/app/src/main/res/values-night/widget_preview_colors.xml index ca6b3f7..7df62c3 100644 --- a/app/src/main/res/values-night/widget_preview_colors.xml +++ b/app/src/main/res/values-night/widget_preview_colors.xml @@ -1,6 +1,8 @@ - #101316 + #2B3742 + #1C252D + #3A4651 #E1E3E6 #A8ADB2 #A3CBE2 diff --git a/app/src/main/res/values-v31/widget_preview_colors.xml b/app/src/main/res/values-v31/widget_preview_colors.xml index 0eb2ece..f706cb2 100644 --- a/app/src/main/res/values-v31/widget_preview_colors.xml +++ b/app/src/main/res/values-v31/widget_preview_colors.xml @@ -1,6 +1,8 @@ - @android:color/system_neutral1_50 + @android:color/system_accent2_100 + @android:color/system_accent2_10 + @android:color/system_neutral2_300 @android:color/system_neutral1_900 @android:color/system_neutral2_700 @android:color/system_accent1_600 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0ef6379..1e940ae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -435,12 +435,94 @@ Upcoming Calendula agenda Calendula month + Calendula quick add + Calendula date + Calendula week strip + + + %1$s, %2$d event + %1$s, %2$d events + + Calendula upcoming + No upcoming events + Calendula today + Nothing left today + Calendula countdown + Now + Ending now + Tomorrow + Ends tomorrow + + Until %1$s + + in %d min + in %d min + + + in %d h + in %d h + + + in %d day + in %d days + + + ends in %d min + ends in %d min + + + ends in %d h + ends in %d h + + + ends in %d day + ends in %d days + + Tap to choose an event to count down to + + %1$s is over + Count down to + Next event + Chosen event + The next timed event in this widget\'s calendars, or the one running now. All-day and declined events are skipped. + Event + None chosen yet + Search events + No events in the coming year match. + Couldn\'t load your events. + + %1$s · repeats + Calendula month and day + No events Refresh New event Open Calendula to grant calendar access Previous month Next month Today + Background + Solid + Tinted + Transparent + Follow app + Content + Time range + Text size + Couldn\'t read this widget\'s settings. + Preview unavailable + Couldn\'t save the widget. Try again. + + %1$s %2$d + + %1$s · %2$s + Calendars + The calendars this widget shows. Calendars you hid in the app stay hidden here too. + All calendars + + + %1$d of %2$d calendar + %1$d of %2$d calendars + Calendars @@ -539,6 +621,9 @@ Team review Lunch with Robin Choir practice + + Weekend trip + Dentist Agenda Agenda range How far ahead the Agenda screen lists events. @@ -554,7 +639,7 @@ Event text %1$d%% Always show today - Keep today at the top of the agenda and its widget, even once nothing is left today. + Keep today at the top of the agenda, even once nothing is left today. Each widget has its own setting. Range bar Show a bar at the top of the agenda naming the dates shown, with a button to switch the range for the session Today @@ -645,12 +730,15 @@ Default fields and behaviour Reminders and delivery Contact birthdays & anniversaries - Agenda widget, Quick Settings tile + Your widgets, Quick Settings tile Export, import, automatic backup Widgets & tiles - Settings for the home-screen widgets and the Quick Settings tile. Add a widget by long-pressing your home screen. + 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. + Each widget has its own look and options. Tap one below to change it. Add a widget by long-pressing your home screen. + On your home screen + No Calendula widgets on your home screen yet. Backup & restore diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index fed97fa..a161487 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -7,4 +7,10 @@ @color/window_background true + + + diff --git a/app/src/main/res/values/widget_preview_colors.xml b/app/src/main/res/values/widget_preview_colors.xml index 98d328b..b584476 100644 --- a/app/src/main/res/values/widget_preview_colors.xml +++ b/app/src/main/res/values/widget_preview_colors.xml @@ -1,11 +1,21 @@ - + - #FBFCFE + #D6E4F0 + #EEF3F8 + #C2CCD6 #191C1F #6E7479 #3B5364 #FFFFFF + #039BE5 + #33B679 + #8E24AA + #F4511E + #D50000 + #000000 + #FFFFFF diff --git a/app/src/main/res/values/widget_preview_styles.xml b/app/src/main/res/values/widget_preview_styles.xml new file mode 100644 index 0000000..0362d0f --- /dev/null +++ b/app/src/main/res/values/widget_preview_styles.xml @@ -0,0 +1,29 @@ + + + + + + + diff --git a/app/src/main/res/xml/appwidget_info_agenda.xml b/app/src/main/res/xml/appwidget_info_agenda.xml index 12abbe3..b8c707c 100644 --- a/app/src/main/res/xml/appwidget_info_agenda.xml +++ b/app/src/main/res/xml/appwidget_info_agenda.xml @@ -8,6 +8,8 @@ android:minResizeHeight="110dp" android:resizeMode="horizontal|vertical" android:widgetCategory="home_screen" + android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity" + android:widgetFeatures="reconfigurable" android:description="@string/widget_agenda_label" android:updatePeriodMillis="1800000" android:previewLayout="@layout/widget_preview_agenda" diff --git a/app/src/main/res/xml/appwidget_info_countdown.xml b/app/src/main/res/xml/appwidget_info_countdown.xml new file mode 100644 index 0000000..e1a1071 --- /dev/null +++ b/app/src/main/res/xml/appwidget_info_countdown.xml @@ -0,0 +1,16 @@ + + diff --git a/app/src/main/res/xml/appwidget_info_date.xml b/app/src/main/res/xml/appwidget_info_date.xml new file mode 100644 index 0000000..10b8368 --- /dev/null +++ b/app/src/main/res/xml/appwidget_info_date.xml @@ -0,0 +1,14 @@ + + diff --git a/app/src/main/res/xml/appwidget_info_month.xml b/app/src/main/res/xml/appwidget_info_month.xml index cfa7acb..075aa51 100644 --- a/app/src/main/res/xml/appwidget_info_month.xml +++ b/app/src/main/res/xml/appwidget_info_month.xml @@ -8,6 +8,8 @@ android:minResizeHeight="150dp" android:resizeMode="horizontal|vertical" android:widgetCategory="home_screen" + android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity" + android:widgetFeatures="reconfigurable" android:description="@string/widget_month_label" android:updatePeriodMillis="1800000" android:previewLayout="@layout/widget_preview_month" diff --git a/app/src/main/res/xml/appwidget_info_month_day.xml b/app/src/main/res/xml/appwidget_info_month_day.xml new file mode 100644 index 0000000..244dbc7 --- /dev/null +++ b/app/src/main/res/xml/appwidget_info_month_day.xml @@ -0,0 +1,16 @@ + + diff --git a/app/src/main/res/xml/appwidget_info_quick_add.xml b/app/src/main/res/xml/appwidget_info_quick_add.xml new file mode 100644 index 0000000..933ce6d --- /dev/null +++ b/app/src/main/res/xml/appwidget_info_quick_add.xml @@ -0,0 +1,14 @@ + + diff --git a/app/src/main/res/xml/appwidget_info_today.xml b/app/src/main/res/xml/appwidget_info_today.xml new file mode 100644 index 0000000..753674b --- /dev/null +++ b/app/src/main/res/xml/appwidget_info_today.xml @@ -0,0 +1,18 @@ + + diff --git a/app/src/main/res/xml/appwidget_info_upcoming.xml b/app/src/main/res/xml/appwidget_info_upcoming.xml new file mode 100644 index 0000000..e80cddb --- /dev/null +++ b/app/src/main/res/xml/appwidget_info_upcoming.xml @@ -0,0 +1,16 @@ + + diff --git a/app/src/main/res/xml/appwidget_info_week_strip.xml b/app/src/main/res/xml/appwidget_info_week_strip.xml new file mode 100644 index 0000000..1c17bff --- /dev/null +++ b/app/src/main/res/xml/appwidget_info_week_strip.xml @@ -0,0 +1,16 @@ + + diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt index 44101d1..74a425b 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt @@ -165,15 +165,12 @@ class SettingsPrefsTest { } @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) assertThat(prefs.agendaScreenRange.first()).isEqualTo(AgendaRange.Month) - assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Month) prefs.setAgendaScreenRange(AgendaRange.Custom(14)) - prefs.setAgendaWidgetRange(AgendaRange.Week) assertThat(prefs.agendaScreenRange.first()).isEqualTo(AgendaRange.Custom(14)) - assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Week) } @Test diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/MonthWindowCenterTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/MonthWindowCenterTest.kt new file mode 100644 index 0000000..7dfca3f --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/MonthWindowCenterTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/WeekOfTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/WeekOfTest.kt new file mode 100644 index 0000000..c4c0af4 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/WeekOfTest.kt @@ -0,0 +1,18 @@ +package de.jeanlucmakiola.calendula.widget + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import org.junit.jupiter.api.Test + +class WeekOfTest { + + @Test + fun `the week starts on the configured day and holds today`() { + val wednesday = LocalDate(2026, 10, 21) + assertThat(weekOf(wednesday, DayOfWeek.MONDAY)).isEqualTo(LocalDate(2026, 10, 19)..LocalDate(2026, 10, 25)) + assertThat(weekOf(wednesday, DayOfWeek.SUNDAY)).isEqualTo(LocalDate(2026, 10, 18)..LocalDate(2026, 10, 24)) + assertThat(weekOf(wednesday, DayOfWeek.WEDNESDAY).start).isEqualTo(wednesday) + assertThat(weekOf(wednesday, DayOfWeek.THURSDAY).start).isEqualTo(LocalDate(2026, 10, 15)) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverSchedulerTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverSchedulerTest.kt deleted file mode 100644 index 8242073..0000000 --- a/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetRolloverSchedulerTest.kt +++ /dev/null @@ -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, - ) - } -} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt index 80d64f9..15804b8 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt @@ -15,25 +15,12 @@ class AgendaScaleTest { // SMALL is the default, so if this fails an agenda widget whose owner never // touched the size setting no longer looks as it did (#51). val m = metricsFor(WidgetSize.SMALL) - assertThat(m.title).isEqualTo(16.sp) - assertThat(m.dayHeader).isEqualTo(13.sp) assertThat(m.eventTitle).isEqualTo(14.sp) assertThat(m.eventTime).isEqualTo(12.sp) assertThat(m.placeholder).isEqualTo(14.sp) assertThat(m.message).isEqualTo(14.sp) - assertThat(m.stripeH).isEqualTo(36.dp) - assertThat(m.iconImage).isEqualTo(22.dp) - assertThat(m.iconBox).isEqualTo(40.dp) + assertThat(m.contentH).isEqualTo(36.dp) assertThat(m.rowVPad).isEqualTo(4.dp) - assertThat(m.dayHeaderTopPad).isEqualTo(10.dp) - } - - @Test - fun `the derived text indent matches the original hardcoded inset`() { - // TEXT_INDENT replaced a hardcoded 19dp; it must still resolve to 19dp or - // day headers and the placeholder line stop aligning with event titles. - assertThat(TEXT_INDENT).isEqualTo(19.dp) - assertThat(TEXT_INDENT).isEqualTo(ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP) } // --- the ramp ------------------------------------------------------------ @@ -43,17 +30,17 @@ class AgendaScaleTest { val steps = WidgetSize.entries.map(::metricsFor) steps.zipWithNext { small, big -> - assertThat(big.title.value).isAtLeast(small.title.value) - assertThat(big.dayHeader.value).isAtLeast(small.dayHeader.value) + assertThat(big.pillWeekday.value).isAtLeast(small.pillWeekday.value) + assertThat(big.pillDay.value).isAtLeast(small.pillDay.value) assertThat(big.eventTitle.value).isAtLeast(small.eventTitle.value) assertThat(big.eventTime.value).isAtLeast(small.eventTime.value) assertThat(big.placeholder.value).isAtLeast(small.placeholder.value) assertThat(big.message.value).isAtLeast(small.message.value) - assertThat(big.stripeH.value).isAtLeast(small.stripeH.value) - assertThat(big.iconImage.value).isAtLeast(small.iconImage.value) - assertThat(big.iconBox.value).isAtLeast(small.iconBox.value) + assertThat(big.contentH.value).isAtLeast(small.contentH.value) + assertThat(big.pillWidth.value).isAtLeast(small.pillWidth.value) assertThat(big.rowVPad.value).isAtLeast(small.rowVPad.value) - assertThat(big.dayHeaderTopPad.value).isAtLeast(small.dayHeaderTopPad.value) + assertThat(big.dayGap.value).isAtLeast(small.dayGap.value) + assertThat(big.monthLabel.value).isAtLeast(small.monthLabel.value) } } @@ -71,20 +58,28 @@ class AgendaScaleTest { // Guards against a future edit turning "more readable" into "absurd". val base = metricsFor(WidgetSize.SMALL) val top = metricsFor(WidgetSize.EXTRA_LARGE) - assertThat(top.title.value / base.title.value).isLessThan(1.5f) + assertThat(top.pillDay.value / base.pillDay.value).isLessThan(1.5f) assertThat(top.eventTitle.value / base.eventTitle.value).isLessThan(1.5f) } // --- font scale ---------------------------------------------------------- @Test - fun `the stripe tracks the system font scale`() { - // The stripe is Dp, the text beside it is sp: without this the two diverge - // at large accessibility font settings and the stripe under-runs the row. + fun `the row height tracks the system font scale`() { + // The row height is Dp, the text in it is sp: without this the two diverge + // at large accessibility font settings and the text outgrows the row. val m = metricsFor(WidgetSize.SMALL) - assertThat(m.scaledForFont(1f).stripeH).isEqualTo(36.dp) - assertThat(m.scaledForFont(1.3f).stripeH.value).isWithin(0.01f).of(46.8f) - assertThat(m.scaledForFont(0.85f).stripeH.value).isWithin(0.01f).of(30.6f) + assertThat(m.scaledForFont(1f).contentH).isEqualTo(36.dp) + assertThat(m.scaledForFont(1.3f).contentH.value).isWithin(0.01f).of(46.8f) + assertThat(m.scaledForFont(0.85f).contentH.value).isWithin(0.01f).of(30.6f) + } + + @Test + fun `the date pill widens with the system font scale`() { + // The pill's text is sp; a fixed-width pill clips "Wed" / "23" at large font settings. + val m = metricsFor(WidgetSize.SMALL) + assertThat(m.scaledForFont(2f).pillWidth).isEqualTo(m.pillWidth * 2) + assertThat(m.scaledForFont(2f).rowHeight).isEqualTo(m.contentH * 2 + m.rowVPad * 2) } @Test @@ -99,7 +94,7 @@ class AgendaScaleTest { // double-count it. val m = metricsFor(WidgetSize.MEDIUM) val scaled = m.scaledForFont(1.3f) - assertThat(scaled.title).isEqualTo(m.title) + assertThat(scaled.pillDay).isEqualTo(m.pillDay) assertThat(scaled.eventTitle).isEqualTo(m.eventTitle) assertThat(scaled.rowVPad).isEqualTo(m.rowVPad) } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownTest.kt new file mode 100644 index 0000000..285e9ed --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownTest.kt @@ -0,0 +1,89 @@ +package de.jeanlucmakiola.calendula.widget.countdown + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.EventResponse +import kotlinx.datetime.TimeZone +import org.junit.jupiter.api.Test +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +class CountdownTest { + + private val zone = TimeZone.UTC + private val now = Instant.parse("2026-10-21T12:00:00Z") + + private fun event( + start: Instant, + end: Instant = start + 1.hours, + id: Long = 1, + allDay: Boolean = false, + response: EventResponse = EventResponse.Going, + ) = EventInstance( + instanceId = id, eventId = id, calendarId = 1, title = "e$id", + start = start, end = end, isAllDay = allDay, color = 0, location = null, response = response, + ) + + @Test + fun `labels step from days to hours to minutes to now`() { + assertThat(countdownLabel(event(now + 30.seconds), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.NOW, 0)) + assertThat(countdownLabel(event(now + 24.minutes + 10.seconds), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.MINUTES, 25)) + assertThat(countdownLabel(event(now + 2.hours + 40.minutes), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.HOURS, 3)) + assertThat(countdownLabel(event(now + 1.hours + 10.minutes), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.HOURS, 1)) + // Tomorrow 23:00 is 35 h away but one calendar day. + assertThat(countdownLabel(event(now + 35.hours), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.DAYS, 1)) + assertThat(countdownLabel(event(now + 12.days), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.DAYS, 12)) + } + + @Test + fun `a running event counts to its end`() { + val running = event(start = now - 20.minutes, end = now + 10.minutes) + assertThat(countdownLabel(running, now, zone)).isEqualTo(CountdownLabel(true, CountdownUnit.MINUTES, 10)) + } + + @Test + fun `the next redraw lands where the label changes`() { + // "in 25 min" turns into "in 24 min" a minute later. + assertThat(nextLabelChange(event(now + 25.minutes), now, zone)).isEqualTo(now + 1.minutes) + // "in 3 h" (2 h 40 min) holds until 2 h 30 min are left. + assertThat(nextLabelChange(event(now + 2.hours + 40.minutes), now, zone)).isEqualTo(now + 10.minutes) + // "in 1 h" holds until the last hour, then counts minutes. + assertThat(nextLabelChange(event(now + 1.hours + 10.minutes), now, zone)).isEqualTo(now + 10.minutes) + // Days change at midnight. + assertThat(nextLabelChange(event(now + 5.days), now, zone)).isEqualTo(Instant.parse("2026-10-22T00:00:00Z")) + } + + @Test + fun `next mode skips all-day, declined and finished events`() { + val allDay = event(now + 1.hours, id = 1, allDay = true) + val declined = event(now + 1.hours, id = 2, response = EventResponse.Declined) + val over = event(now - 2.hours, now - 1.hours, id = 3) + val later = event(now + 3.hours, id = 4) + val running = event(now - 1.hours, now + 2.hours, id = 5) + assertThat(pickNextEvent(listOf(allDay, declined, over, later), now)?.eventId).isEqualTo(4) + // A running event ending before the next one starts comes first. + assertThat(pickNextEvent(listOf(later, running), now)?.eventId).isEqualTo(5) + } + + @Test + fun `a pinned series follows its next occurrence`() { + val past = event(now - 7.days) + val next = event(now + 7.days) + assertThat(pickPinnedOccurrence(listOf(past, next), now, zone)).isEqualTo(next) + assertThat(pickPinnedOccurrence(listOf(past), now, zone)).isNull() + } + + @Test + fun `an all-day event counts in local days west of UTC`() { + val newYork = TimeZone.of("America/New_York") + // 21 Oct, 20:00 in New York; the vacation is all day on the 23rd. + val evening = Instant.parse("2026-10-22T00:00:00Z") + val vacation = event(Instant.parse("2026-10-23T00:00:00Z"), Instant.parse("2026-10-24T00:00:00Z"), allDay = true) + val counted = vacation.counted(newYork) + assertThat(counted.start).isEqualTo(Instant.parse("2026-10-23T04:00:00Z")) + assertThat(countdownLabel(counted, evening, newYork)).isEqualTo(CountdownLabel(false, CountdownUnit.DAYS, 2)) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt new file mode 100644 index 0000000..fa69afc --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/month/MonthGridTest.kt @@ -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): List = + layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone) + + /** July 2026 with [perDay] timed events on every day of the grid. */ + private fun busyWeeks(perDay: Int): List { + 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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/split/MonthGridDaysTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/split/MonthGridDaysTest.kt new file mode 100644 index 0000000..40578e6 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/split/MonthGridDaysTest.kt @@ -0,0 +1,24 @@ +package de.jeanlucmakiola.calendula.widget.split + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import org.junit.jupiter.api.Test + +class MonthGridDaysTest { + + @Test + fun `whole weeks from the week start cover the month and no more`() { + // October 2026 starts on a Thursday and has 31 days. + val october = 2026 * 12 + 9 + val monday = monthGridDays(october, DayOfWeek.MONDAY) + assertThat(monday.first()).isEqualTo(LocalDate(2026, 9, 28)) + assertThat(monday.last()).isEqualTo(LocalDate(2026, 11, 1)) + assertThat(monday).hasSize(35) + + // February 2027 starts on a Monday and fills exactly four weeks. + val february = 2027 * 12 + 1 + assertThat(monthGridDays(february, DayOfWeek.MONDAY)).hasSize(28) + assertThat(monthGridDays(february, DayOfWeek.SUNDAY)).hasSize(35) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/today/TodayRowsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/today/TodayRowsTest.kt new file mode 100644 index 0000000..a0cb36c --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/today/TodayRowsTest.kt @@ -0,0 +1,57 @@ +package de.jeanlucmakiola.calendula.widget.today + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay +import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange +import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions +import de.jeanlucmakiola.calendula.widget.DaysWidgetData +import de.jeanlucmakiola.calendula.widget.WidgetSize +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import org.junit.jupiter.api.Test +import kotlin.time.Duration.Companion.hours +import kotlin.time.Instant + +class TodayRowsTest { + + private val now = Instant.parse("2026-10-21T12:00:00Z") + private val today = LocalDate(2026, 10, 21) + private val tomorrow = LocalDate(2026, 10, 22) + + private fun event(id: Long, start: Instant, end: Instant) = EventInstance( + instanceId = id, eventId = id, calendarId = 1, title = "e$id", + start = start, end = end, isAllDay = false, color = 0, location = null, + ) + + private fun data(past: PastEventDisplay, vararg days: AgendaDay) = DaysWidgetData.Ready( + today = today, + firstDay = today, + lastDay = tomorrow, + days = days.toList(), + weekStart = DayOfWeek.MONDAY, + is24Hour = true, + soften = true, + options = AgendaWidgetOptions(AgendaRange.Day, WidgetSize.SMALL, true, past), + now = now, + ) + + private val ended = event(1, now - 3.hours, now - 1.hours) + private val later = event(2, now + 1.hours, now + 2.hours) + private val next = event(3, now + 22.hours, now + 23.hours) + + @Test + fun `an event still to come lists today, finished ones as the past setting says`() { + val days = arrayOf(AgendaDay(today, listOf(ended, later)), AgendaDay(tomorrow, listOf(next))) + assertThat(todayRows(data(PastEventDisplay.DIM, *days))).isEqualTo(TodayRows.Today(listOf(ended, later))) + assertThat(todayRows(data(PastEventDisplay.HIDE, *days))).isEqualTo(TodayRows.Today(listOf(later))) + } + + @Test + fun `once today is over it looks ahead to tomorrow`() { + val over = data(PastEventDisplay.SHOW, AgendaDay(today, listOf(ended)), AgendaDay(tomorrow, listOf(next))) + assertThat(todayRows(over)).isEqualTo(TodayRows.NothingLeft(listOf(next))) + assertThat(todayRows(data(PastEventDisplay.SHOW))).isEqualTo(TodayRows.NothingLeft(emptyList())) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/upcoming/UpcomingEventsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/upcoming/UpcomingEventsTest.kt new file mode 100644 index 0000000..69dd7d2 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/upcoming/UpcomingEventsTest.kt @@ -0,0 +1,51 @@ +package de.jeanlucmakiola.calendula.widget.upcoming + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay +import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange +import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions +import de.jeanlucmakiola.calendula.widget.DaysWidgetData +import de.jeanlucmakiola.calendula.widget.WidgetSize +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import org.junit.jupiter.api.Test +import kotlin.time.Duration.Companion.hours +import kotlin.time.Instant + +class UpcomingEventsTest { + + private val now = Instant.parse("2026-10-21T12:00:00Z") + + private fun event(id: Long, start: Instant, end: Instant) = EventInstance( + instanceId = id, eventId = id, calendarId = 1, title = "e$id", + start = start, end = end, isAllDay = false, color = 0, location = null, + ) + + private fun data(vararg days: AgendaDay) = DaysWidgetData.Ready( + today = LocalDate(2026, 10, 21), + firstDay = LocalDate(2026, 10, 21), + lastDay = LocalDate(2026, 10, 27), + days = days.toList(), + weekStart = DayOfWeek.MONDAY, + is24Hour = true, + soften = true, + options = AgendaWidgetOptions(AgendaRange.Week, WidgetSize.SMALL, true, PastEventDisplay.SHOW), + now = now, + ) + + @Test + fun `ended events drop out and a multi-day one lists once, on its first day`() { + val ended = event(1, now - 3.hours, now - 1.hours) + val running = event(2, now - 1.hours, now + 30.hours) + val later = event(3, now + 2.hours, now + 3.hours) + val today = AgendaDay(LocalDate(2026, 10, 21), listOf(ended, running, later)) + val tomorrow = AgendaDay(LocalDate(2026, 10, 22), listOf(running)) + + val upcoming = upcomingEvents(data(today, tomorrow)) + + assertThat(upcoming.map { it.second.instanceId }).containsExactly(2L, 3L).inOrder() + assertThat(upcoming.first().first).isEqualTo(LocalDate(2026, 10, 21)) + } +} diff --git a/floret-kit b/floret-kit index 9f90027..6b5ee40 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 9f90027dcd9f1f7a0511bea462ecdf297387b912 +Subproject commit 6b5ee409b3068f2e7a78be7dea80104fed502cd8