diff --git a/CHANGELOG.md b/CHANGELOG.md index 164bb27..e53ebef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.24.0] — 2026-11-06 + +### Added +- **Six new home-screen widgets.** Quick add (1×1) opens a new event on today with the same defaults as the app's + button ([#435]). Date (1×1) shows today's weekday and day number on a rounded shape and opens today ([#376]). Week strip (4×1) shows this week's days with a dot in each event's colour and today filled ([#436]). Upcoming (2×2) shows today's date over your next events, as many as fit ([#437]). Today lists today's events; once they're over it says "Nothing left today" and shows tomorrow's first one or two ([#438]). Countdown counts down to your next timed event, or to an event or series you pick, like a holiday in 12 days ([#439]). +- **A month and day widget.** A compact month grid sits beside the events of one day. Tap a day in the grid to see its events, and tap the list to open that day in the app ([#441]). +- **Each widget has its own settings.** Placing a widget, or long-pressing it and choosing Edit, opens a settings screen with a live preview. Pick a solid, tinted or transparent background, a light or dark theme or the app's, and which calendars it shows. Widgets that list events also have their time range, text size and how past events look. Settings → Widgets lists the widgets on your home screen so you can change them from there. These options used to be shared by all widgets; widgets you already placed keep what you had set ([#292]). +- **Turn off start times in the month view.** Settings → Views → Month → Start times hides the time in front of event titles, so narrow fonts keep more of the title. With it on, the time now only shows when the whole title, or at least its first 12 characters, still fits beside it ([#476]). +- **Drag all-day events.** In the day, 3-day and week views, long-press an event in the all-day strip and move it sideways to another day, or down into the grid to turn it into a timed event ([#296]). +- **Traditional Chinese**, started as a community translation through [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/). It covers only a few screens so far, so the rest still shows in English until it fills out. Pick it under Settings → Language. Thanks to AventineC. + +### Changed +- **The agenda and month widgets have a new look.** The agenda puts each day in its own card, with a round date pill beside the first event and today's pill filled. The month widget has rounded day cells, weekday pills, shaded days outside the month and a + for a new event. The agenda's refresh button is gone, since widgets now update on their own ([#71], [#90]). +- **Tapping a day in the month widget opens that day,** wherever in its cell you tap ([#386]). +- **The widget picker shows what each widget looks like.** On Android 15 and later the previews are the real widgets with sample events, in your language and colours; on older versions they are redrawn to match. +- **Widgets are listed by their name alone,** without "Calendula" in front, and each has a short description in the picker ([#71]). +- **Past events in widgets stay readable when dimmed.** Their background fades further and their text less ([#90]). +- **Events from midnight to midnight show as all-day events.** A timed event that starts and ends at midnight now sits in the all-day strip, shows as "All day" in the agenda and widgets, and its reminder names the day instead of 00:00 – 00:00. The event itself stays as it was saved ([#296]). +- **Cancelled and tentative events are marked.** Cancelled events are struck through and listed last, like declined invites, and tentative ones get the outline used for unanswered invites. Cancelled one-off events and series no longer disappear ([#388]). +- **One notification per event.** An event with several reminders now keeps a single notification, which each later reminder updates in place ([#463]). +- **Go further back and forward in time.** Every view and the date picker now reach from 1600 to 2399 ([#457]). +- **The split month agenda only lists days with events.** In other months it no longer starts with an empty 1st, and a month with nothing in it says so in one row ([#142]). + +### Fixed +- **Widgets show your changes straight away.** After you added, edited or deleted an event, a widget could keep showing the old events until something else redrew it ([#420], [#229]). +- **Widgets follow the app's language.** After you picked a language in Settings, widgets kept the old one until something redrew them ([#90]). +- **The month widget handles busy months.** It could show "Can't show content", or only one or two events per day. Each day now shows as many events as its height fits, and the rest fold into +N ([#372]). +- **Short events late in the day are fully visible.** An event like 23:58–23:59 used to be cut down to a sliver at the bottom of the day, week and 3-day views. It now keeps its usual minimum height and sits flush against midnight ([#483]). +- **The month view no longer crashes after going before 1900** in the day or week view ([#457]). +- **Month pages keep updating.** After a small swipe that snapped back, a month page could stop showing new or edited events, and opening an event from the split view could show its old times ([#440], [#454]). +- **Smoother expand and collapse in the split month view.** The day cells morph cleanly between the two layouts, and week numbers now animate along with them ([#473]). +- **Back clears the selected day in the split month view.** With a day picked, the back gesture used to close the app; it now goes back to the whole month's agenda first ([#142]). + ## [2.23.1] — 2026-10-28 ### Changed @@ -1847,6 +1879,7 @@ automatically, with zero telemetry and no internet permission. [#146]: https://codeberg.org/jlmakiola/calendula/issues/146 [#267]: https://codeberg.org/jlmakiola/calendula/issues/267 [#239]: https://codeberg.org/jlmakiola/calendula/issues/239 +[#296]: https://codeberg.org/jlmakiola/calendula/issues/296 [#297]: https://codeberg.org/jlmakiola/calendula/issues/297 [#298]: https://codeberg.org/jlmakiola/calendula/issues/298 [#309]: https://codeberg.org/jlmakiola/calendula/issues/309 @@ -1896,4 +1929,26 @@ automatically, with zero telemetry and no internet permission. [#306]: https://codeberg.org/jlmakiola/calendula/issues/306 [#178]: https://codeberg.org/jlmakiola/calendula/issues/178 [#116]: https://codeberg.org/jlmakiola/calendula/issues/116 +[#388]: https://codeberg.org/jlmakiola/calendula/issues/388 [#413]: https://codeberg.org/jlmakiola/calendula/issues/413 +[#71]: https://codeberg.org/jlmakiola/calendula/issues/71 +[#90]: https://codeberg.org/jlmakiola/calendula/issues/90 +[#229]: https://codeberg.org/jlmakiola/calendula/issues/229 +[#292]: https://codeberg.org/jlmakiola/calendula/issues/292 +[#372]: https://codeberg.org/jlmakiola/calendula/issues/372 +[#376]: https://codeberg.org/jlmakiola/calendula/issues/376 +[#386]: https://codeberg.org/jlmakiola/calendula/issues/386 +[#420]: https://codeberg.org/jlmakiola/calendula/issues/420 +[#435]: https://codeberg.org/jlmakiola/calendula/issues/435 +[#436]: https://codeberg.org/jlmakiola/calendula/issues/436 +[#437]: https://codeberg.org/jlmakiola/calendula/issues/437 +[#438]: https://codeberg.org/jlmakiola/calendula/issues/438 +[#439]: https://codeberg.org/jlmakiola/calendula/issues/439 +[#440]: https://codeberg.org/jlmakiola/calendula/issues/440 +[#441]: https://codeberg.org/jlmakiola/calendula/issues/441 +[#454]: https://codeberg.org/jlmakiola/calendula/issues/454 +[#457]: https://codeberg.org/jlmakiola/calendula/issues/457 +[#463]: https://codeberg.org/jlmakiola/calendula/issues/463 +[#473]: https://codeberg.org/jlmakiola/calendula/issues/473 +[#476]: https://codeberg.org/jlmakiola/calendula/issues/476 +[#483]: https://codeberg.org/jlmakiola/calendula/issues/483 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5c4f363..5eacd6d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -32,8 +32,8 @@ android { // (up to 2.22.3: 2.22.3 -> 22203; from 2.22.4: 2.23.0-beta.1 -> 2230001, // 2.22.4 -> 2220499), and CI fails if the committed one doesn't match. // See docs/RELEASING.md. - versionCode = 2230199 - versionName = "2.23.1" + versionCode = 2240099 + versionName = "2.24.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -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..24e5c3e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -318,6 +318,16 @@ android:exported="false" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -365,6 +471,7 @@ + diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt index 3f9b88c..2a5a2b3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt @@ -10,9 +10,12 @@ 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 de.jeanlucmakiola.floret.locale.AppLanguage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -43,6 +46,11 @@ class CalendulaApp : Application() { chooseIssueUrl = getString(R.string.report_issue_choose_url), ), ) + // Below API 33 only AppCompat activities get the app language; a process + // woken for a widget formats in it too (#90). + AppLanguage.applyDefault(this) + // Before anything that writes, so the widgets see every change (#420). + widgetEntryPoint().widgetRefreshCoordinator().start() reconcileAutoBackup() reconcileSpecialDates() reconcileCalendarVisibility() @@ -55,10 +63,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/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt index e795433..182a469 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt @@ -30,6 +30,7 @@ import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.floret.locale.AppLanguage import de.jeanlucmakiola.floret.prefs.ThemeMode import de.jeanlucmakiola.floret.prefs.isDark import de.jeanlucmakiola.floret.prefs.isPureBlack @@ -278,6 +279,7 @@ class MainActivity : AppCompatActivity() { super.onResume() // Reaching a running UI means startup succeeded; reset the loop trail. CrashReporter.markHealthy(this) + AppLanguage.syncMirror(this) } override fun onSaveInstanceState(outState: Bundle) { @@ -593,7 +595,9 @@ class MainActivity : AppCompatActivity() { */ fun openDateIntent(context: Context, date: LocalDate, source: CalendarView): Intent = Intent(context, MainActivity::class.java).apply { - data = "calendula://date/$date".toUri() + // The source is part of the URI so widgets of different kinds + // tapping the same date don't share one PendingIntent. + data = "calendula://date/$date/${source.name}".toUri() putExtra(EXTRA_DATE_ISO, date.toString()) putExtra(EXTRA_SOURCE_VIEW, source.name) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) @@ -612,6 +616,7 @@ class MainActivity : AppCompatActivity() { endMillis: Long, source: CalendarView, ): Intent = eventDetailIntent(context, eventId, beginMillis, endMillis).apply { + data = "calendula://event/$eventId/$beginMillis/${source.name}".toUri() putExtra(EXTRA_SOURCE_VIEW, source.name) } 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..7f96a33 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 @@ -564,20 +568,60 @@ class AndroidCalendarDataSource @Inject constructor( } override fun instances(beginMillis: Long, endMillis: Long): List { + val listed = queryInstances(beginMillis, endMillis, null, null) + // A provider that does expand cancelled events already listed them. + val shown = listed.mapTo(HashSet()) { it.eventId } + val cancelled = queryCancelledOneOffs(beginMillis, endMillis).filter { it.eventId !in shown } + return if (cancelled.isEmpty()) listed else (listed + cancelled).sortedBy { it.start } + } + + /** Cancelled one-off events overlapping the window, which AOSP leaves out of Instances (#388). */ + private fun queryCancelledOneOffs(beginMillis: Long, endMillis: Long): List { + val selection = "${CalendarContract.Events.STATUS} = ${CalendarContract.Events.STATUS_CANCELED} AND " + + "${CalendarContract.Events.DELETED} = 0 AND " + + "${CalendarContract.Events.RRULE} IS NULL AND ${CalendarContract.Events.RDATE} IS NULL AND " + + "${CalendarContract.Events.ORIGINAL_ID} IS NULL AND " + + "${CalendarContract.Events.ORIGINAL_SYNC_ID} IS NULL AND " + + "${CalendarContract.Events.DTSTART} < ? AND " + + "COALESCE(${CalendarContract.Events.DTEND}, ${CalendarContract.Events.DTSTART}) >= ?" + return resolver.query( + CalendarContract.Events.CONTENT_URI, + CancelledEventProjection.COLUMNS, + selection, + arrayOf(endMillis.toString(), beginMillis.toString()), + null, + )?.use { c -> c.mapRowsNotNull { toCancelledOneOff() } } ?: emptyList() + } + + override fun eventInstances(eventId: Long, beginMillis: Long, endMillis: Long): List = + // The series' own rows plus its exceptions, so an occurrence edited on + // its own is still one of the series' occurrences. + queryInstances( + beginMillis, + endMillis, + "(${CalendarContract.Instances.EVENT_ID} = ? OR ${CalendarContract.Instances.ORIGINAL_ID} = ?)", + arrayOf(eventId.toString(), eventId.toString()), + ) + + private fun queryInstances( + beginMillis: Long, + endMillis: Long, + extraSelection: String?, + args: Array?, + ): List { ensureObserversRegistered() + // Hide deleted occurrences (cancelled exceptions, #47) but keep a cancelled + // event of its own (#388); NULL status is a normal event. DELETED: #364. + val visible = "(${CalendarContract.Instances.STATUS} IS NULL OR " + + "${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED} OR " + + "(${CalendarContract.Instances.ORIGINAL_ID} IS NULL AND " + + "${CalendarContract.Instances.ORIGINAL_SYNC_ID} IS NULL)) 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() } @@ -601,7 +645,8 @@ class AndroidCalendarDataSource @Inject constructor( .toTypedArray() val selection = "($match) AND " + "${CalendarContract.Events.DELETED} = 0 AND " + - "${CalendarContract.Events.ORIGINAL_ID} IS NULL" + "${CalendarContract.Events.ORIGINAL_ID} IS NULL AND " + + "${CalendarContract.Events.ORIGINAL_SYNC_ID} IS NULL" return resolver.query( CalendarContract.Events.CONTENT_URI, SearchProjection.COLUMNS, 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..3d1308c 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 @@ -10,14 +10,22 @@ import de.jeanlucmakiola.calendula.domain.ics.IcsEvent import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow import kotlin.time.Instant 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 /** @@ -101,6 +109,12 @@ interface CalendarRepository { form: EventForm, ): Long + /** + * Every series split the app makes ([updateEventFromOccurrence]), so what + * follows a series by id can move on to the part that continues it. + */ + val seriesSplits: Flow get() = emptyFlow() + /** * Change a recurring event from [beginMillis] onwards (series split); * returns the new event's `Events._ID`. @@ -128,3 +142,6 @@ class NoSuchEventException(eventId: Long) : /** A ContentResolver write affected no rows or returned no URI. */ class WriteFailedException(operation: String) : RuntimeException("Calendar write failed: $operation") + +/** A "this and following" edit: [oldEventId] now ends where [newEventId] picks up. */ +data class SeriesSplit(val oldEventId: Long, val newEventId: Long) 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..481ae7a 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) @@ -333,7 +351,11 @@ class CalendarRepositoryImpl @Inject constructor( dataSource.updateEventFromOccurrence( eventId, beginMillis, original, updated, allDayReminderTimeMinutes(), ) - } + }.also { newId -> if (newId != eventId) splits.tryEmit(SeriesSplit(eventId, newId)) } + + private val splits = MutableSharedFlow(extraBufferCapacity = 8) + + override val seriesSplits: Flow = splits override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = write { dataSource.deleteOccurrence(eventId, beginMillis) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt index 98dbe3c..1317e9c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt @@ -9,6 +9,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.FormTime import de.jeanlucmakiola.calendula.domain.EventStatus +import de.jeanlucmakiola.calendula.domain.forNewEvent import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent import de.jeanlucmakiola.calendula.domain.nearestTo import kotlinx.datetime.toJavaLocalDate @@ -132,6 +133,8 @@ internal fun buildEventInsertValues( put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone) put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue()) put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue()) + form.status.forNewEvent().takeIf { it != EventStatus.Confirmed } + ?.let { put(CalendarContract.Events.STATUS, it.toProviderStatus()) } form.location.trim().takeIf { it.isNotEmpty() } ?.let { put(CalendarContract.Events.EVENT_LOCATION, it) } form.description.trim().takeIf { it.isNotEmpty() } @@ -179,6 +182,9 @@ internal fun buildEventUpdateValues( if (updated.accessLevel != original.accessLevel) { put(CalendarContract.Events.ACCESS_LEVEL, updated.accessLevel.toProviderValue()) } + if (updated.status != original.status) { + put(CalendarContract.Events.STATUS, updated.status.toProviderStatus()) + } if (updated.colorKey != original.colorKey || updated.color != original.color) { putAll(eventColorColumns(updated.colorKey, updated.color)) } @@ -279,6 +285,7 @@ internal fun buildOccurrenceExceptionValues( put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone) put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue()) put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue()) + put(CalendarContract.Events.STATUS, form.status.forNewEvent().toProviderStatus()) put(CalendarContract.Events.EVENT_LOCATION, form.location.trim().ifEmpty { null }) put(CalendarContract.Events.DESCRIPTION, form.description.trim().ifEmpty { null }) if (form.colorKey == null) putAll(eventColorColumns(null, form.color)) @@ -354,7 +361,8 @@ internal fun buildMovedMasterValues( } put(CalendarContract.Events.AVAILABILITY, snapshot.availability) put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel) - snapshot.status?.let { put(CalendarContract.Events.STATUS, it) } + snapshot.status?.takeIf { it != CalendarContract.Events.STATUS_CANCELED } + ?.let { put(CalendarContract.Events.STATUS, it) } put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null }) put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null }) } @@ -858,7 +866,7 @@ internal fun buildImportedEventValues( // timed rows keep the event's own zone. put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId) put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue()) - put(CalendarContract.Events.STATUS, event.status.toProviderStatus()) + put(CalendarContract.Events.STATUS, event.status.forNewEvent().toProviderStatus()) event.location?.trim()?.takeIf { it.isNotEmpty() } ?.let { put(CalendarContract.Events.EVENT_LOCATION, it) } event.description?.trim()?.takeIf { it.isNotEmpty() } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt index e8b49c6..ff4d9bb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt @@ -38,9 +38,17 @@ internal fun ColumnReader.toEventInstance(): EventInstance? { color = color, location = getString(InstanceProjection.IDX_LOCATION), response = mapEventResponse(getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS)), + status = eventStatus(InstanceProjection.IDX_STATUS), ) } +/** + * A [CancelledEventProjection] row as an instance. Its id is the negated event + * id, so it can't collide with a provider instance id. + */ +internal fun ColumnReader.toCancelledOneOff(): EventInstance? = + toEventInstance()?.let { it.copy(instanceId = -it.eventId) } + /** * `SELF_ATTENDEE_STATUS` as the calendar surfaces read it: a tentative "maybe" * counts as going, and everything that is not an open or refused invitation — diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt index e9dae51..5775d1a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt @@ -62,6 +62,7 @@ internal object InstanceProjection { CalendarContract.Instances.CALENDAR_COLOR, CalendarContract.Instances.EVENT_LOCATION, CalendarContract.Instances.SELF_ATTENDEE_STATUS, + CalendarContract.Instances.STATUS, ) const val IDX_INSTANCE_ID = 0 @@ -75,6 +76,29 @@ internal object InstanceProjection { const val IDX_CALENDAR_COLOR = 8 const val IDX_LOCATION = 9 const val IDX_SELF_ATTENDEE_STATUS = 10 + const val IDX_STATUS = 11 +} + +/** + * A cancelled one-off straight from the Events table, laid out on + * [InstanceProjection]'s indices so [toCancelledOneOff] reuses its mapper. AOSP + * writes no Instances rows for a cancelled event (#388). + */ +internal object CancelledEventProjection { + val COLUMNS: Array = arrayOf( + CalendarContract.Events._ID, + CalendarContract.Events._ID, + CalendarContract.Events.CALENDAR_ID, + CalendarContract.Events.TITLE, + CalendarContract.Events.DTSTART, + CalendarContract.Events.DTEND, + CalendarContract.Events.ALL_DAY, + CalendarContract.Events.EVENT_COLOR, + CalendarContract.Events.CALENDAR_COLOR, + CalendarContract.Events.EVENT_LOCATION, + CalendarContract.Events.SELF_ATTENDEE_STATUS, + CalendarContract.Events.STATUS, + ) } internal object EventDetailProjection { @@ -198,6 +222,7 @@ internal object SearchProjection { // Excerpted, not just filtered on: a hit has to show what it matched. CalendarContract.Events.DESCRIPTION, CalendarContract.Events.SELF_ATTENDEE_STATUS, + CalendarContract.Events.STATUS, ) const val IDX_ID = 0 @@ -214,6 +239,7 @@ internal object SearchProjection { const val IDX_RDATE = 11 const val IDX_DESCRIPTION = 12 const val IDX_SELF_ATTENDEE_STATUS = 13 + const val IDX_STATUS = 14 } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt index 1b7dcf2..81d4f86 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt @@ -35,5 +35,6 @@ internal fun ColumnReader.toSearchResult(): EventInstance? { isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() || !getString(SearchProjection.IDX_RDATE).isNullOrEmpty(), response = mapEventResponse(getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS)), + status = eventStatus(SearchProjection.IDX_STATUS), ) } 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..793c01e 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 @@ -146,6 +146,7 @@ class SettingsPrefs @Inject constructor( val pastEventDisplay: Flow = prefs.flow(SettingsKeys.pastEventDisplay) val dimCompletedEvents: Flow = prefs.flow(SettingsKeys.dimCompletedEvents) val showWeekNumbers: Flow = prefs.flow(SettingsKeys.showWeekNumbers) + val monthShowStartTimes: Flow = prefs.flow(SettingsKeys.monthShowStartTimes) val monthViewStyle: Flow = prefs.flow(SettingsKeys.monthViewStyle) val timelineScale: Flow = prefs.flow(SettingsKeys.timelineScale) val multiDayCount: Flow = prefs.flow(SettingsKeys.multiDayCount) @@ -154,8 +155,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) @@ -214,6 +213,7 @@ class SettingsPrefs @Inject constructor( suspend fun setPastEventDisplay(mode: PastEventDisplay) = prefs.set(SettingsKeys.pastEventDisplay, mode) suspend fun setDimCompletedEvents(enabled: Boolean) = prefs.set(SettingsKeys.dimCompletedEvents, enabled) suspend fun setShowWeekNumbers(enabled: Boolean) = prefs.set(SettingsKeys.showWeekNumbers, enabled) + suspend fun setMonthShowStartTimes(enabled: Boolean) = prefs.set(SettingsKeys.monthShowStartTimes, enabled) suspend fun setMonthViewStyle(style: MonthViewStyle) = prefs.set(SettingsKeys.monthViewStyle, style) suspend fun setTimelineScale(scale: TimelineScale) = prefs.set(SettingsKeys.timelineScale, scale) suspend fun setMultiDayCount(count: Int) = prefs.set(SettingsKeys.multiDayCount, count) @@ -231,8 +231,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) @@ -449,6 +447,9 @@ object SettingsKeys { */ val showWeekNumbers: Pref = booleanPref("show_week_numbers", false) + /** Whether Month chips may show the start time before the title when wide enough (#219, #476). */ + val monthShowStartTimes: Pref = booleanPref("month_show_start_times", true) + /** How the Month view lays itself out (#38, #53). */ val monthViewStyle: Pref = enumPref("month_view_style", MonthViewStyle.Paged) @@ -497,13 +498,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 +515,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/data/reminders/ReminderActionReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt index b4d5e94..c0bd756 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt @@ -38,6 +38,8 @@ class ReminderActionReceiver : BroadcastReceiver() { ACTION_DISMISS -> notifier.cancel(alert) ACTION_SNOOZE -> { + // Tapped on a stale view: the reminder that replaced it stays put. + if (notifier.isSuperseded(alert)) return // Cancel now so the notification doesn't linger until the alarm; // the snooze delay read is the only async work. notifier.cancel(alert) @@ -50,6 +52,8 @@ class ReminderActionReceiver : BroadcastReceiver() { ACTION_SHOW -> { goAsync { + // A later reminder posted while this one was snoozed has taken its place. + if (notifier.isSuperseded(alert)) return@goAsync if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) { notifier.post(alert) } @@ -82,12 +86,14 @@ class ReminderActionReceiver : BroadcastReceiver() { /** * An explicit intent to this receiver carrying [alert] as extras. The * data URI duplicates no information but is what keeps two reminders' - * `PendingIntent`s apart — `filterEquals` never compares extras. + * `PendingIntent`s apart — `filterEquals` never compares extras. The + * snooze alarm is the exception: one per occurrence, so a later snooze + * replaces an earlier one and a newer reminder can cancel it. */ fun intent(context: Context, action: String, alert: ReminderAlert): Intent = Intent(context, ReminderActionReceiver::class.java).apply { this.action = action - data = "calendula://reminder/${alert.key}".toUri() + data = "calendula://reminder/${identity(alert, action)}".toUri() putExtra(EXTRA_ALERT_KEY, alert.key) putExtra(EXTRA_EVENT_ID, alert.eventId) putExtra(EXTRA_CALENDAR_ID, alert.calendarId) @@ -101,7 +107,7 @@ class ReminderActionReceiver : BroadcastReceiver() { /** * A stable request code per (alert, action), so one notification's * PendingIntents stay distinct. The shift keeps the action slot intact; - * the top bits it drops are separated by [intent]'s per-reminder URI. + * the top bits it drops are separated by [intent]'s URI. */ fun requestCode(alert: ReminderAlert, action: String): Int { val actionOffset = when (action) { @@ -111,9 +117,12 @@ class ReminderActionReceiver : BroadcastReceiver() { ACTION_OPEN -> 4 else -> 0 } - return (alert.key.toInt() shl 3) + actionOffset + return (identity(alert, action).toInt() shl 3) + actionOffset } + private fun identity(alert: ReminderAlert, action: String): Long = + if (action == ACTION_SHOW) alert.occurrenceKey else alert.key + private fun alertFrom(intent: Intent): ReminderAlert? { if (!intent.hasExtra(EXTRA_ALERT_KEY)) return null return ReminderAlert( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderAlert.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderAlert.kt index 8a5630a..cb9780d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderAlert.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderAlert.kt @@ -1,6 +1,7 @@ package de.jeanlucmakiola.calendula.data.reminders import de.jeanlucmakiola.calendula.domain.reminders.PlannedReminder +import de.jeanlucmakiola.calendula.domain.reminders.occurrenceKey /** * One reminder as the notification layer needs it: what to show, and the stable @@ -20,6 +21,13 @@ data class ReminderAlert( val isAllDay: Boolean, ) +/** + * The occurrence [ReminderAlert] belongs to. Keys the notification and the + * snooze alarm, so a later reminder for the same occurrence replaces the earlier + * one instead of stacking beside it (#463). + */ +val ReminderAlert.occurrenceKey: Long get() = occurrenceKey(eventId, beginMillis) + fun PlannedReminder.toAlert(): ReminderAlert = ReminderAlert( key = key, eventId = instance.eventId, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt index 07a0004..215554b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt @@ -6,6 +6,7 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.os.Build +import android.os.Bundle import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat @@ -28,13 +29,13 @@ import javax.inject.Inject import javax.inject.Singleton /** - * Posts one notification per due reminder on a dedicated channel. Tapping opens + * Posts one notification per occurrence on a dedicated channel. Tapping opens * the event's detail screen. * - * The tag is the reminder's stable key, so a scan that posts the same reminder - * again — a catch-up pass overlapping the alarm that already fired — replaces - * its notification silently ([NotificationCompat.Builder.setOnlyAlertOnce]) - * instead of stacking a second one. + * The tag is the [occurrenceKey], so each reminder for an occurrence replaces + * the one before it in place (#463). A different reminder alerts afresh; the + * same one posted again — a catch-up pass overlapping the alarm that already + * fired — replaces its notification silently. */ @Singleton class ReminderNotifier @Inject constructor( @@ -65,7 +66,7 @@ class ReminderNotifier @Inject constructor( suspend fun post(alert: ReminderAlert) { if (isSilenced(alert.calendarId)) return ensureChannel() - val title = alert.title.ifBlank { context.getString(R.string.event_untitled) } + val title = displayTitle(alert) val is24Hour = settingsPrefs.timeFormat.first() .is24Hour(android.text.format.DateFormat.is24HourFormat(context)) val zone = ZoneId.systemDefault() @@ -96,7 +97,8 @@ class ReminderNotifier @Inject constructor( .setCategory(NotificationCompat.CATEGORY_EVENT) .setPriority(NotificationCompat.PRIORITY_HIGH) .setAutoCancel(true) - .setOnlyAlertOnce(true) + .setOnlyAlertOnce(shownKey(alert) == alert.key) + .addExtras(Bundle().apply { putLong(EXTRA_REMINDER_KEY, alert.key) }) .setContentIntent(detailIntent(alert)) .addAction( R.drawable.ic_notification_snooze, @@ -109,20 +111,58 @@ class ReminderNotifier @Inject constructor( actionIntent(alert, ReminderActionReceiver.ACTION_DISMISS), ) .build() + val manager = NotificationManagerCompat.from(context) + cancelLegacy(alert) try { - NotificationManagerCompat.from(context) - .notify(alert.key.toString(), NOTIFICATION_ID, notification) + manager.notify(alert.occurrenceKey.toString(), NOTIFICATION_ID, notification) } catch (e: SecurityException) { // POST_NOTIFICATIONS was revoked between canPost() and here. Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e) } } - /** Remove a posted reminder (snooze re-shows it later; dismiss is final). */ + /** + * Remove [alert]'s notification (snooze re-shows it later; dismiss is final), + * unless a later reminder has replaced it — an action tapped on a stale view + * mustn't take the newer one with it. + */ fun cancel(alert: ReminderAlert) { - NotificationManagerCompat.from(context).cancel(alert.key.toString(), NOTIFICATION_ID) + val manager = NotificationManagerCompat.from(context) + if (shownKey(alert) == alert.key) manager.cancel(alert.occurrenceKey.toString(), NOTIFICATION_ID) + // Posted before reminders shared a notification, under the reminder's own key. + manager.cancel(alert.key.toString(), NOTIFICATION_ID) } + /** Whether a different reminder for [alert]'s occurrence is on screen now. */ + fun isSuperseded(alert: ReminderAlert): Boolean = shownKey(alert).let { it != null && it != alert.key } + + /** The key of the reminder showing for [alert]'s occurrence, if any. */ + private fun shownKey(alert: ReminderAlert): Long? { + val tag = alert.occurrenceKey.toString() + return NotificationManagerCompat.from(context).activeNotifications + .firstOrNull { it.tag == tag && it.id == NOTIFICATION_ID } + ?.notification?.extras + ?.takeIf { it.containsKey(EXTRA_REMINDER_KEY) } + ?.getLong(EXTRA_REMINDER_KEY) + } + + /** + * Remove a reminder for the same occurrence posted before reminders shared a + * notification. It carries no reminder key; its time and title identify it. + */ + private fun cancelLegacy(alert: ReminderAlert) { + val manager = NotificationManagerCompat.from(context) + manager.activeNotifications + .filter { it.id == NOTIFICATION_ID && it.notification.channelId == CHANNEL_ID } + .filter { !it.notification.extras.containsKey(EXTRA_REMINDER_KEY) } + .filter { it.notification.`when` == alert.beginMillis } + .filter { it.notification.extras.getCharSequence(NotificationCompat.EXTRA_TITLE)?.toString() == displayTitle(alert) } + .forEach { manager.cancel(it.tag, it.id) } + } + + private fun displayTitle(alert: ReminderAlert): String = + alert.title.ifBlank { context.getString(R.string.event_untitled) } + private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent = PendingIntent.getBroadcast( context, @@ -157,8 +197,10 @@ class ReminderNotifier @Inject constructor( const val TAG = "ReminderNotifier" const val CHANNEL_ID = "reminders" - // One id, distinct tags: the tag (alert id) already keys the + // One id, distinct tags: the tag (occurrence) already keys the // notification, so a fixed id keeps cancellation/replacement simple. const val NOTIFICATION_ID = 1 + + const val EXTRA_REMINDER_KEY = "de.jeanlucmakiola.calendula.reminder_key" } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderScanner.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderScanner.kt index 38ea963..a67c9b6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderScanner.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderScanner.kt @@ -43,6 +43,7 @@ class ReminderScanner @Inject constructor( private val source: ReminderInstanceSource, private val calendarDataSource: CalendarDataSource, private val notifier: ReminderNotifier, + private val snoozes: ReminderSnoozeScheduler, private val alarms: ReminderAlarmScheduler, private val state: ReminderStatePrefs, private val settingsPrefs: SettingsPrefs, @@ -104,7 +105,11 @@ class ReminderScanner @Inject constructor( ) if (notifier.canPost()) { - schedule.due.forEach { notifier.post(it.toAlert()) } + schedule.due.forEach { + val alert = it.toAlert() + snoozes.cancel(alert) + notifier.post(alert) + } } // Advance even when nothing could be posted, so muting notifications // doesn't build a backlog. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderSnoozeScheduler.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderSnoozeScheduler.kt index 0c9e05c..c92e712 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderSnoozeScheduler.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderSnoozeScheduler.kt @@ -33,4 +33,16 @@ class ReminderSnoozeScheduler @Inject constructor( ) alarmManager.setWakeup(triggerAtMillis, pendingIntent) } + + /** Drop a snooze still pending for [alert]'s occurrence, so it can't re-show over a newer reminder. */ + fun cancel(alert: ReminderAlert) { + val pendingIntent = PendingIntent.getBroadcast( + context, + ReminderActionReceiver.requestCode(alert, ReminderActionReceiver.ACTION_SHOW), + ReminderActionReceiver.intent(context, ReminderActionReceiver.ACTION_SHOW, alert), + PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE, + ) ?: return + context.getSystemService()?.cancel(pendingIntent) + pendingIntent.cancel() + } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt index c169d70..9e983d8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.data.reminders +import de.jeanlucmakiola.calendula.domain.runsMidnightToMidnight import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter import java.time.DayOfWeek import java.time.Instant @@ -11,6 +12,8 @@ import java.time.format.FormatStyle import java.time.format.TextStyle import java.time.temporal.ChronoUnit import java.util.Locale +import kotlinx.datetime.toKotlinTimeZone +import kotlin.time.Instant as KInstant /** * The one line of time context in a reminder notification. Pure so it can be @@ -32,7 +35,8 @@ import java.util.Locale * - all-day, multi-day: "Tomorrow – 12 Jun 2026" (relative start, explicit end) * * All-day instances store UTC midnights with an exclusive end, so they are read - * in UTC and the end day is the last *covered* day (issue #308). + * in UTC and the end day is the last *covered* day (issue #308). A timed event + * from local midnight to local midnight reads like one too, in [zone] (#296). */ fun reminderTimeText( beginMillis: Long, @@ -47,11 +51,17 @@ fun reminderTimeText( tomorrowLabel: String, yesterdayLabel: String, ): String { - if (isAllDay) { + val wholeDays = !isAllDay && runsMidnightToMidnight( + KInstant.fromEpochMilliseconds(beginMillis), + KInstant.fromEpochMilliseconds(endMillis), + zone.toKotlinTimeZone(), + ) + if (isAllDay || wholeDays) { + val dayZone = if (isAllDay) ZoneOffset.UTC else zone val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) // (atZone().toLocalDate() instead of LocalDate.ofInstant — API 34+) - val firstDay = Instant.ofEpochMilli(beginMillis).atZone(ZoneOffset.UTC).toLocalDate() - val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate() + val firstDay = Instant.ofEpochMilli(beginMillis).atZone(dayZone).toLocalDate() + val lastDay = Instant.ofEpochMilli(endMillis).atZone(dayZone).toLocalDate() .minusDays(1) .coerceAtLeast(firstDay) val start = relativeDayPrefix( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventDisplayOrder.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventDisplayOrder.kt index 1240c5f..3c7a402 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventDisplayOrder.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventDisplayOrder.kt @@ -1,18 +1,35 @@ package de.jeanlucmakiola.calendula.domain import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlin.time.Instant /** Lower values appear first in lists and get the upper visible lanes in grids. */ fun EventInstance.displayPriority(zone: TimeZone): Int = when { - isDeclined -> 3 + isCrossedOut -> 3 spansMultipleDays(zone) -> 0 - isAllDay -> 1 + coversWholeDays(zone) -> 1 else -> 2 } -/** Shared order for agenda and split day lists. Grid bars add their lane geometry. */ -fun eventDisplayComparator(zone: TimeZone): Comparator = - compareBy { it.displayPriority(zone) } - .thenBy { it.start } - .thenBy { it.title } - .thenBy { it.instanceId } +/** + * Shared order for agenda and split day lists. Grid bars add their lane geometry. + * Each event's priority is worked out once, not on every comparison. + */ +fun List.sortedForDisplay(zone: TimeZone): List = + map { Triple(it, it.displayPriority(zone), it.displayStart(zone)) } + .sortedWith( + compareBy> { it.second } + .thenBy { it.third } + .thenBy { it.first.title } + .thenBy { it.first.instanceId }, + ) + .map { it.first } + +/** + * Where an event starts for ordering: a whole-day event at local midnight of its + * first day, since an all-day one is stored at UTC midnight and would otherwise + * sort around timed events by the device's offset (#296). + */ +internal fun EventInstance.displayStart(zone: TimeZone): Instant = + if (coversWholeDays(zone)) spanFirstDay(zone).atStartOfDayIn(zone) else start diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt index bbd9646..3afbed5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt @@ -42,6 +42,8 @@ data class EventForm( val reminders: List = emptyList(), val availability: Availability = Availability.Busy, val accessLevel: AccessLevel = AccessLevel.Default, + /** Confirmed / Tentative / Cancelled (#388). */ + val status: EventStatus = EventStatus.Confirmed, /** * Bare RRULE value (`Events.RRULE` convention, no "RRULE:" prefix); null * means a one-off event. May hold rules the simple picker can't express — @@ -113,6 +115,7 @@ enum class EventFormField { Reminders, Recurrence, Availability, + Status, Visibility, Color, Attendees, @@ -178,6 +181,7 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): reminders = reminders.map { it.minutes }.distinct().sorted(), availability = availability, accessLevel = accessLevel, + status = status, rrule = rrule.normalizedRrule(), // The provider fills EVENT_COLOR from the key, so [color] is the // swatch either way; a null colour means the event inherits its @@ -213,7 +217,7 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): * re-deriving with the same occurrence would mask an externally moved * event. Guests are covered (the form writes editable attendees), so an * external attendee change now also trips the conflict check. Still not - * covered: status, the user's own response, reminder methods, the + * covered: the user's own response, reminder methods, the * organizer/resource rows, and a recurring event's duration. */ data class EditSnapshot( @@ -243,6 +247,22 @@ internal fun allDayFormTimes(start: LocalDate, endExclusive: LocalDate?): Pair = buildSet { if (reminders.isNotEmpty()) add(EventFormField.Reminders) if (rrule != null) add(EventFormField.Recurrence) if (availability != Availability.Busy) add(EventFormField.Availability) + if (status != EventStatus.Confirmed) add(EventFormField.Status) if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility) if (colorKey != null || color != null) add(EventFormField.Color) if (attendees.isNotEmpty()) add(EventFormField.Attendees) @@ -311,4 +332,5 @@ fun EventDetail.toDuplicateForm(canModify: Boolean, zone: TimeZone): EventForm = ).copy( rrule = null, calendarId = instance.calendarId.takeIf { canModify }, + status = status.forNewEvent(), ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt index b77b8af..fd3c17a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventShift.kt @@ -173,8 +173,11 @@ fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm { return copy(start = movedStart, end = (startAt + span).toFormTime(zone)) } -/** The form with new start and end, each in the form's own zone — a resize. All-day is untouched. */ +/** + * The form with new start and end, each in the form's own zone — a resize. An + * all-day event becomes timed: one dragged out of the all-day strip. + */ fun EventForm.resizedTo(newStart: Instant, newEnd: Instant, deviceZone: TimeZone): EventForm { - if (isAllDay) return this - return withTimes(newStart, newEnd, resolvedZone(deviceZone)) + val timed = copy(isAllDay = false) + return timed.withTimes(newStart, newEnd, timed.resolvedZone(deviceZone)) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt index 0dad2ed..11e39eb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt @@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.domain import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.toLocalDateTime import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Instant @@ -72,6 +73,8 @@ data class EventInstance( * (#180, #230). */ val response: EventResponse = EventResponse.Going, + /** The event's own Confirmed / Tentative / Cancelled (`Events.STATUS`, #388). */ + val status: EventStatus = EventStatus.Confirmed, ) /** @@ -94,16 +97,33 @@ enum class EventResponse { Declined, } -/** Shorthand for the declined case, which most surfaces test on its own (#180). */ -val EventInstance.isDeclined: Boolean get() = response == EventResponse.Declined +/** + * Struck through on every surface and sorted last: an invitation you declined + * (#180), or an event its organizer cancelled (#388). Neither plans reminders. + */ +val EventInstance.isCrossedOut: Boolean + get() = response == EventResponse.Declined || status == EventStatus.Cancelled + +/** + * Drawn as an outline, still undecided: an invitation you have not answered + * (#230), or a tentative event (#388). + */ +val EventInstance.isOutlined: Boolean + get() = !isCrossedOut && (response == EventResponse.Invited || status == EventStatus.Tentative) /** * Whether this event has finished relative to [now] — its end is at or before * the current instant. An in-progress event (already started but not yet ended) - * is *not* considered ended. All-day events end at the exclusive next-midnight, - * so they only count as ended once their day is fully over. + * is *not* considered ended. All-day events end at the exclusive next-midnight + * in [zone] rather than the UTC midnight they are stored on, so they only count + * as ended once their local day is fully over. */ -fun EventInstance.hasEnded(now: Instant): Boolean = end <= now +fun EventInstance.hasEnded(now: Instant, zone: TimeZone = TimeZone.currentSystemDefault()): Boolean = + localEnd(zone) <= now + +/** [EventInstance.end], with an all-day event's UTC midnight moved to local midnight in [zone]. */ +fun EventInstance.localEnd(zone: TimeZone): Instant = + if (isAllDay) end.toLocalDateTime(TimeZone.UTC).date.atStartOfDayIn(zone) else end /** * The zone this event's calendar dates live in: the device [zone] for timed @@ -130,6 +150,20 @@ fun EventInstance.spanLastDay(zone: TimeZone): LocalDate { fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean = spanFirstDay(zone) != spanLastDay(zone) +/** + * Whether this event fills whole days in [zone], so the grids and lists lay it + * out with the all-day events: an all-day event, or a timed one running from + * local midnight to local midnight (#296). The event itself stays timed. + */ +fun EventInstance.coversWholeDays(zone: TimeZone): Boolean = isAllDay || runsMidnightToMidnight(start, end, zone) + +/** Whether a timed span from [start] to [end] runs from local midnight to local midnight in [zone] (#296). */ +fun runsMidnightToMidnight(start: Instant, end: Instant, zone: TimeZone): Boolean = + end > start && start.isStartOfDay(zone) && end.isStartOfDay(zone) + +private fun Instant.isStartOfDay(zone: TimeZone): Boolean = + this == toLocalDateTime(zone).date.atStartOfDayIn(zone) + data class EventDetail( val instance: EventInstance, val description: String?, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt index 1467e04..436e8ac 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/ics/ParsedIcsForm.kt @@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.domain.ics import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.allDayFormTimes +import de.jeanlucmakiola.calendula.domain.forNewEvent import de.jeanlucmakiola.calendula.domain.normalizedRrule import de.jeanlucmakiola.calendula.domain.toFormTime import kotlinx.datetime.TimeZone @@ -35,6 +36,7 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm { description = description.orEmpty(), reminders = semanticReminderMinutes(), availability = availability, + status = status.forNewEvent(), rrule = recurrenceRule.normalizedRrule(), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderPlan.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderPlan.kt index 559cc51..c70b87d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderPlan.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderPlan.kt @@ -32,19 +32,26 @@ data class PlannedReminder( val minutes: Int, val alarmMillis: Long, ) { + /** The occurrence this reminder belongs to; it shares one notification with its siblings (#463). */ + val occurrenceKey: Long = occurrenceKey(instance.eventId, instance.beginMillis) + /** - * Stable identity, keying the notification tag and the snooze/dismiss + * Stable identity of this one reminder, keying the snooze/dismiss * `PendingIntent`s. Survives reboot, re-scan and reinstall. */ - val key: Long = key(instance.eventId, instance.beginMillis, minutes) + val key: Long = mix64(occurrenceKey + minutes) +} - private companion object { - fun key(eventId: Long, beginMillis: Long, minutes: Int): Long { - var h = eventId * 1_000_003L - h = (h xor beginMillis) * 31L - return h + minutes - } - } +/** Stable identity of one occurrence, shared by every reminder on it. */ +fun occurrenceKey(eventId: Long, beginMillis: Long): Long = + mix64(mix64(eventId) + beginMillis) + +/** SplitMix64's finaliser: spreads close inputs across all 64 bits. */ +private fun mix64(value: Long): Long { + var z = value + -0x61c8864680b583ebL + z = (z xor (z ushr 30)) * -0x40a7b892e31b1a47L + z = (z xor (z ushr 27)) * -0x6b2fb644ecceee15L + return z xor (z ushr 31) } /** What one scan concluded: post these now, and wake up again at [nextAlarmMillis]. */ @@ -146,7 +153,8 @@ private fun allDayAlarmMillis( * * Due means the fire instant falls in `(lastFiredMillis, nowMillis]`, so a scan * running twice cannot post twice while a late one still catches up. Reminders - * whose event has ended are dropped ([isStillRelevant]). [nextAlarmMillis] is + * whose event has ended are dropped ([isStillRelevant]), and of several due on + * one occurrence only the latest is kept — they'd replace each other anyway (#463). [nextAlarmMillis] is * capped at [horizonMillis] so the lookahead window keeps rolling forward. */ fun scheduleReminders( @@ -158,7 +166,8 @@ fun scheduleReminders( val due = planned .filter { it.alarmMillis in (lastFiredMillis + 1)..nowMillis } .filter { it.instance.isStillRelevant(nowMillis) } - .distinctBy { it.key } + .groupBy { it.occurrenceKey } + .values.map { siblings -> siblings.maxBy { it.alarmMillis } } .sortedWith(compareBy({ it.instance.beginMillis }, { it.key })) val nextPending = planned .filter { it.alarmMillis > nowMillis } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt index f1a9a1f..06df935 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRows.kt @@ -32,7 +32,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventInstance -import de.jeanlucmakiola.calendula.domain.isDeclined +import de.jeanlucmakiola.calendula.domain.isCrossedOut import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey import de.jeanlucmakiola.calendula.ui.common.declinedTitle @@ -148,7 +148,7 @@ internal fun AgendaEventRow( val title = event.title.ifBlank { stringResource(R.string.event_untitled) } GroupedRow( modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier, - title = declinedTitle(title, event.isDeclined), + title = declinedTitle(title, event.isCrossedOut), summary = AnnotatedString(agendaTimeSummary(event, day, zone)), position = position, minHeight = 64.dp, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index 1b2871e..7a55c64 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.ui.agenda +import de.jeanlucmakiola.calendula.ui.common.FabClearance import de.jeanlucmakiola.calendula.ui.common.PublishChrome import de.jeanlucmakiola.calendula.ui.common.ScreenActions import de.jeanlucmakiola.calendula.ui.common.ScreenBody @@ -432,7 +433,7 @@ private fun AgendaList( modifier = modifier.nestedScroll(loadOnScrollUp), state = listState, // Bottom inset clears the FAB stack so the last row stays tappable. - contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp), + contentPadding = PaddingValues(top = 8.dp, bottom = FabClearance), ) { agendaDays( days = days, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt index e67e78b..d2d09b1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt @@ -1,7 +1,8 @@ package de.jeanlucmakiola.calendula.ui.agenda import de.jeanlucmakiola.calendula.domain.EventInstance -import de.jeanlucmakiola.calendula.domain.eventDisplayComparator +import de.jeanlucmakiola.calendula.domain.coversWholeDays +import de.jeanlucmakiola.calendula.domain.sortedForDisplay import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.spanFirstDay import de.jeanlucmakiola.calendula.domain.spanLastDay @@ -18,7 +19,7 @@ import kotlin.time.Instant * surface only formats the instants into its own locale/24h string. */ sealed interface AgendaTimeLabel { - /** An all-day event, or a whole in-between day of a multi-day span. */ + /** An event filling whole days (#296), or a whole in-between day of a multi-day span. */ data object AllDay : AgendaTimeLabel /** The first day of a multi-day timed event: when it begins. */ data class Starts(val start: Instant) : AgendaTimeLabel @@ -30,7 +31,7 @@ sealed interface AgendaTimeLabel { /** The [AgendaTimeLabel] for [event] as it appears on [day], resolved in [zone]. */ fun agendaTimeLabel(event: EventInstance, day: LocalDate, zone: TimeZone): AgendaTimeLabel { - if (event.isAllDay) return AgendaTimeLabel.AllDay + if (event.coversWholeDays(zone)) return AgendaTimeLabel.AllDay val firstDay = event.spanFirstDay(zone) val lastDay = event.spanLastDay(zone) return when { @@ -87,7 +88,7 @@ fun groupAgendaDays( return byDay.map { (date, dayEvents) -> AgendaDay( date = date, - events = dayEvents.sortedWith(eventDisplayComparator(zone)), + events = dayEvents.sortedForDisplay(zone), ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDatePickerDialog.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDatePickerDialog.kt index 9c6af33..e54004a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDatePickerDialog.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDatePickerDialog.kt @@ -30,7 +30,9 @@ fun CalendarDatePickerDialog( onDismiss: () -> Unit, ) { val state = rememberDatePickerState( - initialSelectedDateMillis = initial.toEpochDays() * MILLIS_PER_DAY, + initialSelectedDateMillis = initial.coerceIn(CalendarSpan.FIRST_DAY, CalendarSpan.LAST_DAY) + .toEpochDays() * MILLIS_PER_DAY, + yearRange = CalendarSpan.years, ) DatePickerDialog( onDismissRequest = onDismiss, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt index 18a8fa6..af1b53f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt @@ -38,6 +38,9 @@ import de.jeanlucmakiola.calendula.R /** The standard FAB's size, shared by the selection bar that unrolls from it (#141). */ val FabSize = 56.dp +/** Bottom inset that lets a scrolling view's last row or hour scroll clear of the FAB. */ +val FabClearance = 96.dp + /** What the FAB is while an event is selected (#141): the toggle for its action bar. */ @Stable class FabSelection( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarSpan.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarSpan.kt new file mode 100644 index 0000000..ef1dc7b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarSpan.kt @@ -0,0 +1,14 @@ +package de.jeanlucmakiola.calendula.ui.common + +import kotlinx.datetime.LocalDate + +/** + * The dates every view can show and every date picker offers. One span for all + * of them, so no view can hand another a date it has no page for (#457). + */ +object CalendarSpan { + val FIRST_DAY: LocalDate = LocalDate(1600, 1, 1) + val LAST_DAY: LocalDate = LocalDate(2399, 12, 31) + + val years: IntRange get() = FIRST_DAY.year..LAST_DAY.year +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventFormFieldVisuals.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventFormFieldVisuals.kt index 91d84a5..33f3b7d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventFormFieldVisuals.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventFormFieldVisuals.kt @@ -11,6 +11,7 @@ import androidx.compose.material.icons.filled.People import androidx.compose.material.icons.filled.Place import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Repeat +import androidx.compose.material.icons.filled.TaskAlt import androidx.compose.ui.graphics.vector.ImageVector import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventFormField @@ -29,6 +30,7 @@ fun eventFormFieldLabel(field: EventFormField): Int = when (field) { EventFormField.Reminders -> R.string.event_detail_reminders EventFormField.Recurrence -> R.string.event_detail_recurrence EventFormField.Availability -> R.string.event_edit_availability + EventFormField.Status -> R.string.event_edit_status EventFormField.Visibility -> R.string.event_edit_visibility EventFormField.Color -> R.string.event_edit_color EventFormField.Attendees -> R.string.event_edit_attendees @@ -41,6 +43,7 @@ fun eventFormFieldIcon(field: EventFormField): ImageVector = when (field) { EventFormField.Reminders -> Icons.Default.Notifications EventFormField.Recurrence -> Icons.Default.Repeat EventFormField.Availability -> Icons.Default.EventAvailable + EventFormField.Status -> Icons.Default.TaskAlt EventFormField.Visibility -> Icons.Default.Lock EventFormField.Color -> Icons.Default.Palette EventFormField.Attendees -> Icons.Default.People diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt index abf3978..f827dc9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventMoveHost.kt @@ -91,6 +91,7 @@ fun EventMoveHost( MoveOutcome.WriteDenied -> stringResource(R.string.event_move_write_denied) MoveOutcome.Gone -> stringResource(R.string.event_move_gone) MoveOutcome.BlockedSeriesEnd -> stringResource(R.string.event_move_blocked_series_end) + MoveOutcome.BlockedCancelledSeries -> stringResource(R.string.event_move_blocked_cancelled_series) MoveOutcome.Failed -> stringResource(R.string.event_move_failed) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventPaint.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventPaint.kt index f29321e..99db3d0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventPaint.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventPaint.kt @@ -21,8 +21,8 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import de.jeanlucmakiola.calendula.domain.EventInstance -import de.jeanlucmakiola.calendula.domain.EventResponse -import de.jeanlucmakiola.calendula.domain.isDeclined +import de.jeanlucmakiola.calendula.domain.isCrossedOut +import de.jeanlucmakiola.calendula.domain.isOutlined /** Stroke an invitation you have not answered is outlined with. */ val EVENT_OUTLINE_WIDTH = 1.dp @@ -60,13 +60,13 @@ fun timedBlockCuts(continuesBefore: Boolean, continuesAfter: Boolean): ChipCuts /** * How one event's chip or block is painted, which turns on your answer to its - * invitation (#180, #230). + * invitation (#180, #230) and on the event's own status (#388). * - * An invitation you have not answered is drawn as an outline: its calendar's - * colour on the border and on the title, over the surface the chip sits on. So - * it holds a chip's shape and a chip's weight without claiming the filled - * container an event you are going to gets. A declined one keeps the fill and - * its strike-through. + * An invitation you have not answered, or a tentative event, is drawn as an + * outline: its calendar's colour on the border and on the title, over the + * surface the chip sits on. So it holds a chip's shape and a chip's weight + * without claiming the filled container an event you are going to gets. A + * declined or cancelled one keeps the fill and its strike-through. */ @Immutable data class EventPaint( @@ -87,7 +87,7 @@ data class EventPaint( @Composable fun eventPaint(event: EventInstance, dark: Boolean): EventPaint { val soften = LocalSoftenColors.current - if (event.response == EventResponse.Invited) { + if (event.isOutlined) { // Harmonised even when the setting is off. Raw mode exists so a filled // container matches what the sync source paints, and an outlined chip // has no container — what it has is coloured *text*, which needs the @@ -117,7 +117,7 @@ fun eventPaint(event: EventInstance, dark: Boolean): EventPaint { outline = null, titleInk = eventInk(fill, alpha = TITLE_INK_ALPHA), secondaryInk = eventInk(fill, alpha = SECONDARY_INK_ALPHA), - decoration = declinedDecoration(event.isDeclined), + decoration = declinedDecoration(event.isCrossedOut), titleWeight = null, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventTimeStyle.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventTimeStyle.kt index fdd38e9..3008179 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventTimeStyle.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventTimeStyle.kt @@ -3,15 +3,12 @@ package de.jeanlucmakiola.calendula.ui.common import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.sp import de.jeanlucmakiola.floret.locale.currentLocale @@ -45,33 +42,17 @@ fun inlineTimeLabel(time: String?, title: String, timeInk: Color): AnnotatedStri append(title) } -/** - * The characters of title that have to survive a time prefix for it to be worth - * its place — lowercase Latin of average advance, priced at the surface's own - * text style rather than guessed in dp. - */ -private const val TITLE_SAMPLE = "notepad" - /** The widest wall-clock time in either convention: two-digit hour, and a meridiem in 12-hour. */ private const val SAMPLE_HOUR = 12 private const val SAMPLE_MINUTE = 45 /** - * The narrowest run of [style] text that may carry a time in front of a title: - * wide enough for the widest time in the current convention plus [TITLE_SAMPLE]. - * - * Measured rather than a device breakpoint, so it follows the font scale, the - * 12/24-hour setting, the locale's own time format and whatever else has - * already been taken off the width. + * The widest time the current convention writes, for pricing a time prefix by + * measurement: it follows the 12/24-hour setting and the locale's own format. */ @Composable -fun rememberInlineTimeWidth(style: TextStyle): Dp { - val measurer = rememberTextMeasurer() - val density = LocalDensity.current +fun rememberWidestTimeSample(): String { val locale = currentLocale() - val sample = formatTimeOfDay(SAMPLE_HOUR, SAMPLE_MINUTE, LocalUse24HourFormat.current, locale) - return remember(sample, style, density, measurer) { - val label = inlineTimeLabel(sample, TITLE_SAMPLE, Color.Unspecified) - with(density) { measurer.measure(label, style).size.width.toDp() } - } + val is24Hour = LocalUse24HourFormat.current + return remember(is24Hour, locale) { formatTimeOfDay(SAMPLE_HOUR, SAMPLE_MINUTE, is24Hour, locale) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt index c7f0aef..d04e548 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourGutter.kt @@ -56,7 +56,8 @@ private const val DIMMED_HOUR_ALPHA = 0.3f * The timeline's hour gutter. Scrolls in sync with the day columns through the * shared [scrollState], and while [dragController] holds a lifted block it dims * the hour labels and floats a badge with the drag's current start time at the - * row the block would land on. + * row the block would land on. Its content ends in the same [FabClearance] + * the day columns do, or the two drift apart at the bottom of the scroll. */ @Composable fun HourGutter( @@ -86,7 +87,8 @@ fun HourGutter( .width(GUTTER_WIDTH) .padding(start = GUTTER_CONTENT_START_INSET) .fillMaxHeight() - .verticalScroll(scrollState), + .verticalScroll(scrollState) + .padding(bottom = FabClearance), ) { Column { (0 until 24).forEach { h -> diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdges.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdges.kt index 67cd722..19fe634 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdges.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdges.kt @@ -70,27 +70,24 @@ data class OutOfViewEvents(val above: TimedBlock?, val below: TimedBlock?) { /** Which edge of the viewport a hidden event sits past. */ enum class ViewportEdge { Top, Bottom } -/** Where [block]'s rendered bottom lands, in minutes — short blocks keep a floor. */ -internal fun renderedEndMin(block: TimedBlock): Float = - maxOf(block.endMin.toFloat(), block.startMin + MIN_EVENT_FRACTION * 60f) - /** * The blocks of one column lying out of a viewport spanning [topMin]..[bottomMin] * (minutes of the day), nearest first. A block leaves this set as soon as any - * part of its rendered height enters the viewport. + * part of its [renderedSpan] enters the viewport. */ fun outOfViewEvents( blocks: List, topMin: Float, bottomMin: Float, ): OutOfViewEvents { - val above = blocks - .filter { renderedEndMin(it) <= topMin } - .maxWithOrNull(compareBy({ renderedEndMin(it) }, { it.startMin })) - val below = blocks - .filter { it.startMin >= bottomMin } - .minWithOrNull(compareBy({ it.startMin }, { -renderedEndMin(it) })) - return OutOfViewEvents(above, below) + val spans = blocks.map { it to it.renderedSpan() } + val above = spans + .filter { (_, span) -> span.bottomMin <= topMin } + .maxWithOrNull(compareBy({ it.second.bottomMin }, { it.first.startMin })) + val below = spans + .filter { (_, span) -> span.topMin >= bottomMin } + .minWithOrNull(compareBy({ it.second.topMin }, { -it.second.bottomMin })) + return OutOfViewEvents(above?.first, below?.first) } /** @@ -107,8 +104,9 @@ fun revealScrollTarget( marginPx: Float, maxScroll: Int, ): Int { - val startPx = block.startMin / 60f * hourPx - val endPx = renderedEndMin(block) / 60f * hourPx + val span = block.renderedSpan() + val startPx = span.topMin / 60f * hourPx + val endPx = span.bottomMin / 60f * hourPx val target = when (edge) { ViewportEdge.Top -> startPx - marginPx ViewportEdge.Bottom -> minOf(endPx + marginPx - viewportPx, startPx - marginPx) @@ -293,6 +291,7 @@ private fun EdgeBar( modifier: Modifier = Modifier, ) { block ?: return + val span = remember(block) { block.renderedSpan() } val color = eventFill(block.event.color, dark, LocalSoftenColors.current) val label = stringResource( if (edge == ViewportEdge.Top) R.string.timeline_earlier_events else R.string.timeline_later_events, @@ -303,8 +302,8 @@ private fun EdgeBar( .height(EDGE_TOUCH_HEIGHT) .graphicsLayer { val gapPx = when (edge) { - ViewportEdge.Top -> scrollState.value - renderedEndMin(block) / 60f * hourPx - ViewportEdge.Bottom -> block.startMin / 60f * hourPx - + ViewportEdge.Top -> scrollState.value - span.bottomMin / 60f * hourPx + ViewportEdge.Bottom -> span.topMin / 60f * hourPx - (scrollState.value + scrollState.viewportSize) } alpha = edgeBarAlpha(gapPx, fadeLeadPx) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PageStateCache.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PageStateCache.kt index 5e92a83..9ddaa92 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PageStateCache.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PageStateCache.kt @@ -1,22 +1,26 @@ package de.jeanlucmakiola.calendula.ui.common -import de.jeanlucmakiola.floret.android.stateWhileSubscribed import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.plus +import kotlinx.coroutines.launch /** * One shared state per pager page — a week, a day, a month — so a page and the * screen's anchor read the same query, and the pages either side stay loaded * across a swipe. * - * Every entry shares on its own job, cancelled when [keep] lets it go: a - * `stateIn` on the ViewModel's scope would stay running, holding its last state, - * until the ViewModel itself was cleared. Main thread only. + * An entry queries only while something collects it, stopping [STOP_TIMEOUT_MILLIS] + * after the last collector leaves, and starts again when one comes back. Letting + * an entry go only forgets it: a page still holding it keeps a working state + * however far the pager composes or prefetches, and however long the screen sat + * stopped (#440, #454). A forgotten entry nobody collects holds no coroutine, so + * paging through a year doesn't keep a year of queries around. Main thread only. * * @param keep whether a cached key is still worth holding once [get] asks for another. */ @@ -26,28 +30,54 @@ class PageStateCache( private val keep: (cached: K, requested: K) -> Boolean, private val load: (K) -> Flow, ) { - private class Entry(val state: StateFlow, val job: Job) - - private val entries = HashMap>() + private val entries = HashMap() /** The number of entries held, for tests. */ internal val size: Int get() = entries.size fun get(key: K): StateFlow { - val iterator = entries.iterator() - while (iterator.hasNext()) { - val (cached, entry) = iterator.next() - if (cached != key && !keep(cached, key)) { - entry.job.cancel() - iterator.remove() + // One still collected stays, so the anchor asking for it again shares its query. + entries.entries.removeAll { (cached, entry) -> + cached != key && !keep(cached, key) && !entry.isCollected + } + return entries.getOrPut(key) { Entry(key) } + } + + @OptIn(ExperimentalForInheritanceCoroutinesApi::class) + private inner class Entry(private val key: K) : StateFlow { + private val state = MutableStateFlow(initial) + private var collectors = 0 + private var loading: Job? = null + private var stopping: Job? = null + + val isCollected: Boolean get() = collectors > 0 + + override val value: S get() = state.value + override val replayCache: List get() = state.replayCache + + override suspend fun collect(collector: FlowCollector): Nothing { + collectors++ + stopping?.cancel() + stopping = null + if (loading?.isActive != true) { + loading = scope.launch { load(key).collect { state.value = it } } + } + try { + state.collect(collector) + } finally { + if (--collectors == 0) { + stopping = scope.launch { + delay(STOP_TIMEOUT_MILLIS) + loading?.cancel() + loading = null + } + } } } - return entries.getOrPut(key) { - val job = SupervisorJob(scope.coroutineContext[Job]) - Entry( - state = load(key).stateWhileSubscribed(scope + job, initial), - job = job, - ) - }.state + } + + private companion object { + /** Long enough to ride out a configuration change, as `stateWhileSubscribed` does. */ + const val STOP_TIMEOUT_MILLIS = 5_000L } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PastEvents.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PastEvents.kt index e827a1a..d0fea92 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PastEvents.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/PastEvents.kt @@ -30,7 +30,7 @@ fun EventInstance.isPastOn( day: LocalDate, now: Instant, zone: TimeZone = TimeZone.currentSystemDefault(), -): Boolean = hasEnded(now) || day < now.toLocalDateTime(zone).date +): Boolean = hasEnded(now, zone) || day < now.toLocalDateTime(zone).date /** Opacity applied to a completed/past event chip when it is dimmed. */ const val EventDimAlpha = 0.4f @@ -41,19 +41,19 @@ const val EventDimAlpha = 0.4f * is still something the organiser expects you at. Null for everything else, so * it drops straight into a `Text`'s `textDecoration`. */ -fun declinedDecoration(isDeclined: Boolean): TextDecoration? = - if (isDeclined) TextDecoration.LineThrough else null +fun declinedDecoration(isCrossedOut: Boolean): TextDecoration? = + if (isCrossedOut) TextDecoration.LineThrough else null /** [declinedDecoration] for the rows that take styled text rather than a `String`. */ -fun declinedTitle(title: String, isDeclined: Boolean): AnnotatedString = if (isDeclined) { +fun declinedTitle(title: String, isCrossedOut: Boolean): AnnotatedString = if (isCrossedOut) { AnnotatedString(title, SpanStyle(textDecoration = TextDecoration.LineThrough)) } else { AnnotatedString(title) } /** [declinedTitle] over already-styled text, e.g. a search hit's marked runs. */ -fun declinedTitle(title: AnnotatedString, isDeclined: Boolean): AnnotatedString = - if (!isDeclined) { +fun declinedTitle(title: AnnotatedString, isCrossedOut: Boolean): AnnotatedString = + if (!isCrossedOut) { title } else { buildAnnotatedString { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt index ba1f245..dad5cb1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModel.kt @@ -6,6 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.calendula.data.time.ZoneSource import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException +import de.jeanlucmakiola.calendula.domain.canSaveApartFromSeries import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventFormProblem @@ -57,7 +58,10 @@ sealed interface MoveTarget { */ data class ByDays(val days: Int) : MoveTarget - /** New start and end instants — a timeline resize, which changes the length. */ + /** + * New start and end instants — a timeline resize, which changes the length, + * or an all-day event dragged into the time grid, which becomes timed. + */ data class Span(val start: Instant, val end: Instant) : MoveTarget } @@ -114,6 +118,12 @@ sealed interface MoveOutcome { /** The drop would push the series past its own `UNTIL`, generating nothing. */ data object BlockedSeriesEnd : MoveOutcome + /** + * A cancelled series only moves as a whole, and this drop would change which + * days it falls on (#388). + */ + data object BlockedCancelledSeries : MoveOutcome + data object Failed : MoveOutcome } @@ -203,7 +213,10 @@ class RescheduleViewModel @Inject constructor( val canRealign: Boolean, /** The series row's DTSTART date after the move — what its `UNTIL` must clear. */ val newAnchorDate: LocalDate, - ) + ) { + /** A timed event's new length, rather than an all-day one dragged into the grid. */ + val isResize: Boolean get() = request.target is MoveTarget.Span && !original.isAllDay + } /** * Take a drop, unless one is already being written. False means nothing will @@ -227,15 +240,21 @@ class RescheduleViewModel @Inject constructor( busy = false return@launch } - if (!prepared.isRecurring) { + // A cancelled series only moves as a whole (#388). + val seriesOnly = !prepared.original.canSaveApartFromSeries() + if (!prepared.isRecurring || (seriesOnly && prepared.canRealign)) { write(prepared, RecurringWriteScope.AllEvents) busy = false + } else if (seriesOnly) { + _outcome.value = MoveOutcome.BlockedCancelledSeries + _abandoned.value += 1 + busy = false } else { // Still busy: the scope dialog is now the thing in flight. pending = prepared _scopePrompt.value = MoveScopePrompt( occurrenceOnly = !prepared.canRealign, - resize = request.target is MoveTarget.Span, + resize = prepared.isResize, ) } } @@ -459,9 +478,9 @@ class RescheduleViewModel @Inject constructor( val anchorZone = if (original.isAllDay) TimeZone.UTC else original.resolvedZone(zone) val anchorMinute = detail.instance.start.toLocalDateTime(anchorZone) .time.toMillisecondOfDay() / MILLIS_PER_MINUTE - val delta = ( - shifted.start.time.toMillisecondOfDay() - original.start.time.toMillisecondOfDay() - ) / MILLIS_PER_MINUTE + // An all-day form's time is a placeholder; its anchor sits at midnight. + val fromMillis = if (original.isAllDay) 0 else original.start.time.toMillisecondOfDay() + val delta = (shifted.start.time.toMillisecondOfDay() - fromMillis) / MILLIS_PER_MINUTE return anchorMinute + delta in 0 until MINUTES_PER_DAY } @@ -494,7 +513,7 @@ class RescheduleViewModel @Inject constructor( /** Where a resized occurrence now ends; null for a move, which keeps its length. */ private fun resizedEndMillis(prepared: PreparedMove): Long? { - if (prepared.request.target !is MoveTarget.Span) return null + if (!prepared.isResize) return null val form = prepared.updated return form.endInstant(form.resolvedZone(zoneSource.current())).toEpochMilliseconds() } 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/common/TimelineDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt index 73c5db7..9ed6cf4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt @@ -78,6 +78,9 @@ import kotlin.time.Instant /** Start granularity a dragged block snaps to, matching the big calendar apps. */ const val DRAG_SNAP_MINUTES: Int = 15 +/** How far below a strip drag's timed block top the finger holds it, in minutes. */ +private const val STRIP_GRAB_MINUTES = 15 + /** How close to a timeline edge the finger must get before the view scrolls. */ private val AUTO_SCROLL_EDGE = 64.dp @@ -126,6 +129,21 @@ data class TimelineDrag( val slot: Triple get() = Triple(date, startMin, eventSpanMin) } +/** + * An event lifted off the all-day strip: held over the strip it moves [dayShift] + * days and stays whole-day, held over the time grid it becomes a timed block. + */ +data class StripDrag(val event: EventInstance, val dayShift: Int = 0, val overGrid: Boolean = false) + +/** Where a strip drag asks its event to go. */ +sealed interface StripDrop { + val event: EventInstance + + data class Days(override val event: EventInstance, val days: Int) : StripDrop + + data class Timed(override val event: EventInstance, val start: Instant, val end: Instant) : StripDrop +} + /** The event a long press left selected (#141). */ data class TimelineSelection( val event: EventInstance, @@ -249,9 +267,10 @@ internal fun dragPieceBounds( ): DragPieceBounds { val top = if (slice.continuesBefore) 0f else insetPx val bottom = if (slice.continuesAfter) 0f else insetPx - val full = maxOf(slice.spanMin / 60f * hourPx, MIN_EVENT_FRACTION * hourPx) + val span = renderedSpan(slice.startMin, slice.startMin + slice.spanMin, slice.continuesAfter) + val full = (span.bottomMin - span.topMin) / 60f * hourPx return DragPieceBounds( - top = slice.startMin / 60f * hourPx + top, + top = span.topMin / 60f * hourPx + top, height = (full - top - bottom).coerceAtLeast(0f), ) } @@ -309,12 +328,13 @@ internal fun draftStartMin(minutes: Float): Int = internal fun draftBlock(draft: EventInstance, date: LocalDate, zone: TimeZone): TimedBlock? { // Clipped to the day the way the grid lays out events, so it's drawn the same across a DST change (#387). // The end is exclusive, so a draft ending at midnight belongs to the day before. + val dayEnd = date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone) val start = maxOf(draft.start, date.atStartOfDayIn(zone)) - val end = minOf(draft.end, date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)) + val end = minOf(draft.end, dayEnd) if (end <= start) return null val startMin = wallClockMinutes(start, date, zone) val endMin = wallClockEndMinutes(startMin, start, end, date, zone).coerceAtMost(MINUTES_PER_DAY) - return TimedBlock(draft, startMin, endMin, lane = 0, laneCount = 1) + return TimedBlock(draft, startMin, endMin, lane = 0, laneCount = 1, continuesAfter = draft.end > dayEnd) } /** Which end of an event a resize handle moves (#141). */ @@ -629,6 +649,28 @@ class TimelineDragController { /** The resize in progress, or null for a plain drag (#141). */ private var resize: ResizeSession? = null + /** The strip drag in progress; over the grid it drives [drag] through a timed stand-in. */ + var stripDrag: StripDrag? by mutableStateOf(null) + private set + + /** A strip drop held at its new days until the strip redraws the event there. */ + var stripSettling: StripDrag? by mutableStateOf(null) + private set + + /** Whether the strip now draws [stripSettling] at its new days itself. */ + var stripSeated: Boolean by mutableStateOf(false) + private set + + /** What [abandonedTicks] stood at when [stripSettling] was let go. */ + var stripAbandonedMark: Int = 0 + private set + + /** The shown day the finger picked the strip bar up on. */ + private var stripGrabIndex = 0 + + /** Whether a strip drag over the grid has cleared its top edge, and may now scroll up. */ + private var stripScrollArmed = false + fun begin( block: TimedBlock, clipOffsetMin: Int, @@ -667,6 +709,69 @@ class TimelineDragController { begin(block, clipOffsetMin = 0, zone, titleLines = 1, pointerInRoot = pointerInRoot, blockInRoot = blockInRoot) } + /** + * Lift [event] off the all-day strip. Over the time grid it is dragged as a + * block [draftMinutes] long, whose top sits a little above the finger. + */ + fun beginStrip(event: EventInstance, zone: TimeZone, pointerInRoot: Offset) { + val grid = geometry.grid?.takeIf { it.isAttached } ?: return + if (geometry.columnWidthPx <= 0f || geometry.days.isEmpty()) return + deselect() + if (stripSettling?.event?.instanceId == event.instanceId) releaseStrip() + stripScrollArmed = false + val timed = event.copy(isAllDay = false, end = event.start + draftMinutes.minutes) + source = TimedBlock(timed, 0, draftMinutes, lane = 0, laneCount = 1) + clipOffsetMin = 0 + dragZone = zone + dragDuration = draftMinutes.minutes + dragKept = null + titleLines = 1 + settling = null + isDragging = true + liftedInstanceId = event.instanceId + grab = Offset(0f, geometry.hourPx * STRIP_GRAB_MINUTES / 60f) + pointer = pointerInRoot + pickupPointer = pointerInRoot + pointerMoved = false + originSlot = null + stripGrabIndex = dayIndexAt(pointerInRoot.x, grid.positionInRoot().x) + stripDrag = StripDrag(event) + } + + /** The shown day under [x], clamped to the days on screen. */ + private fun dayIndexAt(x: Float, originX: Float): Int = + column(((x - originX) / geometry.columnWidthPx).toInt().coerceIn(0, geometry.days.lastIndex)) + + /** End a strip drag, handing back where it landed — null when it went nowhere. */ + fun finishStrip(): StripDrop? { + val held = stripDrag ?: return null + val landed = drag + val zone = dragZone + cancel() + if (held.overGrid && landed != null && zone != null && !landed.edgeOnly) { + settle(landed) + val start = TimelineDrop(landed.event, landed.date, landed.startMin).startInstant(zone) + return StripDrop.Timed(held.event, start, start + draftMinutes.minutes) + } + if (held.overGrid || held.dayShift == 0) return null + stripSettling = held + stripSeated = false + stripAbandonedMark = abandonedTicks?.value ?: 0 + drops++ + return StripDrop.Days(held.event, held.dayShift) + } + + /** Let the held strip drop go, once the strip shows the event at its new days or the move failed. */ + fun releaseStrip() { + stripSettling = null + stripSeated = false + } + + /** Note that the strip draws the settling drop at its new days. */ + fun seatStrip() { + if (stripSettling != null) stripSeated = true + } + fun move(pointerInRoot: Offset) { pointer = pointerInRoot if (!pointerMoved && (pointerInRoot - pickupPointer).getDistance() > slopPx) pointerMoved = true @@ -675,6 +780,7 @@ class TimelineDragController { fun cancel() { resize = null + stripDrag = null source = null clipOffsetMin = 0 dragZone = null @@ -755,12 +861,16 @@ class TimelineDragController { /** Keep [landed]'s copy on the target as [settling] until [release]; [end] is a resize's. */ private fun settle(landed: TimelineDrag, end: Instant? = null) { + // Landed on midnight to midnight, the event moves to the all-day strip (#296). + val leavesGrid = !landed.event.isDraft && landed.eventSpanMin > 0 && + landed.eventStartMin.mod(MINUTES_PER_DAY) == 0 && + landed.eventSpanMin % MINUTES_PER_DAY == 0 settlingSelected = selection?.event?.instanceId == landed.event.instanceId // An edge piece is a hint for the finger, not a preview of the landing // slot: the event belongs to a day this timeline has no column for, so // [noteGrid] can never seat it and it would sit at the top of today for // the whole settle timeout, long after the event moved off-screen. - settling = if (landed.edgeOnly) landed.copy(pieces = emptyList()) else landed + settling = if (landed.edgeOnly || leavesGrid) landed.copy(pieces = emptyList()) else landed liftedInstanceId = landed.event.instanceId settledOrigin = landed.event.eventId to landed.event.start settledAbandonedMark = abandonedTicks?.value ?: 0 @@ -795,7 +905,9 @@ class TimelineDragController { val edgeIndex = geometry.days.indexOf(span.day.plus(edgeDay, DateTimeUnit.DAY)) session.column = edgeIndex.takeIf { it >= 0 } ?.let { column(it) } - session.grabMin = (pointerResizeMinutes(session) ?: 0f) - + // The top handle sits on the drawn top, which a short block near midnight lifts (#483). + val lift = if (edge == ResizeEdge.Top) block.startMin - block.renderedSpan().topMin else 0f + session.grabMin = (pointerResizeMinutes(session) ?: 0f) + lift - if (edge == ResizeEdge.Top) session.startMin else session.endMin originSlot = null recompute() @@ -855,7 +967,9 @@ class TimelineDragController { session.column = held val dayOffset = (days[column(held)].toEpochDays() - session.day.toEpochDays()).toInt() session.edgeDay = dayOffset - return dayOffset * MINUTES_PER_DAY + (pointer.y - origin.y) / hourPx * 60f + // Held to its column's midnight: the clearance under it is not the next day. + val minutes = ((pointer.y - origin.y) / hourPx * 60f).coerceAtMost(MINUTES_PER_DAY.toFloat()) + return dayOffset * MINUTES_PER_DAY + minutes } private fun recomputeResize(session: ResizeSession) { @@ -928,6 +1042,29 @@ class TimelineDragController { recomputeResize(it) return } + stripDrag?.let { + recomputeStrip(it) + return + } + recomputeDrag() + } + + /** Over the strip the bar moves by whole days; past its bottom edge it is a timed block. */ + private fun recomputeStrip(held: StripDrag) { + val grid = geometry.grid?.takeIf { it.isAttached } ?: return + val viewport = geometry.viewport?.takeIf { it.isAttached } ?: return + if (geometry.columnWidthPx <= 0f || geometry.days.isEmpty()) return + if (pointer.y >= viewport.boundsInRoot().top) { + stripDrag = held.copy(overGrid = true) + recomputeDrag() + } else { + drag = null + val shift = dayIndexAt(pointer.x, grid.positionInRoot().x) - stripGrabIndex + stripDrag = held.copy(dayShift = shift, overGrid = false) + } + } + + private fun recomputeDrag() { val block = source ?: return val grid = geometry.grid?.takeIf { it.isAttached } ?: return val hourPx = geometry.hourPx @@ -1013,11 +1150,18 @@ class TimelineDragController { } private fun edgeStep(): Float { + val strip = stripDrag + if (strip?.overGrid == false) return 0f val viewport = geometry.viewport?.takeIf { it.isAttached } ?: return 0f val edge = geometry.edgePx if (edge <= 0f) return 0f val bounds = viewport.boundsInRoot() val fromTop = pointer.y - bounds.top + // Coming down out of the strip the finger starts inside the top edge. + if (strip != null && !stripScrollArmed) { + if (fromTop < edge) return 0f + stripScrollArmed = true + } val fromBottom = bounds.bottom - pointer.y return when { fromTop < edge -> -(edge - fromTop) / edge * geometry.stepPx @@ -1077,10 +1221,6 @@ fun TimedBlock.clipOffsetMinutes(day: LocalDate, zone: TimeZone): Int { fun TimedBlock.continuesBefore(day: LocalDate, zone: TimeZone): Boolean = event.start < day.atStartOfDayIn(zone) -/** True when the event runs past [day]'s midnight, so this block is cut there. */ -fun TimedBlock.continuesAfter(day: LocalDate, zone: TimeZone): Boolean = - event.end > day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone) - /** * The instant a drop asks for — the *event's* new start, so the tail of an * overnight event drops back onto the day its own start belongs to. Read in the @@ -1177,7 +1317,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = // Both live here rather than beside the controller: they read [drag], which // changes every frame, and this composable is the one that is meant to. - DragSnapHaptics(controller.drag?.slot, controller.drops) + DragSnapHaptics(controller.drag?.slot ?: controller.stripDrag?.dayShift, controller.drops) // Only once the finger has moved: a long press held near an edge must not // scroll the drop target out from under a finger that only means to select. LaunchedEffect(controller.isDragging && controller.pointerMoved) { @@ -1201,6 +1341,15 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = controller.release() } + // The same hold for a bar moved along the all-day strip. + LaunchedEffect(controller.stripSettling, moveInFlight, controller.stripSeated, abandoned) { + if (controller.stripSettling == null || moveInFlight) return@LaunchedEffect + if (abandoned == controller.stripAbandonedMark) { + delay(if (controller.stripSeated) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS) + } + controller.releaseStrip() + } + Box( modifier = modifier .fillMaxSize() diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineHours.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineHours.kt index 56ab4c6..5632707 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineHours.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineHours.kt @@ -43,14 +43,21 @@ data class TimelineHours( /** * The scroll offset that puts [startHour] at the top of a timeline laid out - * with [maxValue] scroll range over a [viewportPx] viewport, or null while that + * with [maxValue] scroll range over a [viewportPx] viewport, [clearancePx] of + * which is the empty inset under midnight, or null while that * layout is not yet at [hourPx] per hour — the scale changes a frame before the * content grows to match it, and scrolling against the old layout would land on * the wrong hour. */ -internal fun startHourScroll(maxValue: Int, viewportPx: Int, startHour: Int, hourPx: Float): Int? { +internal fun startHourScroll( + maxValue: Int, + viewportPx: Int, + startHour: Int, + hourPx: Float, + clearancePx: Int = 0, +): Int? { if (viewportPx <= 0 || maxValue < 0 || maxValue == Int.MAX_VALUE) return null - val laidOutHourPx = (maxValue + viewportPx) / 24f + val laidOutHourPx = (maxValue + viewportPx - clearancePx) / 24f if (abs(laidOutHourPx - hourPx) > 1f) return null return (startHour * laidOutHourPx).roundToInt().coerceIn(0, maxValue) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScale.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScale.kt index 227e5f6..a45b291 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScale.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScale.kt @@ -4,6 +4,8 @@ import androidx.annotation.StringRes import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY +import de.jeanlucmakiola.calendula.ui.week.TimedBlock /** * How tall one hour is drawn in the week and day timelines (#56). @@ -59,8 +61,9 @@ sealed interface TimelineScale { } /** - * Hour height for this scale. [viewportHeight] is the visible height of the - * scrolling timeline and is only consulted by [TimelineScale.FitDay]. + * Hour height for this scale. [viewportHeight] is the height the day is fitted + * to, the scrolling timeline's [dayFitHeight], and is not consulted by the + * fixed presets. * * The fit-day result is clamped: below [FIT_DAY_MIN] the 24 gutter labels stop * being legible, and above [FIT_DAY_MAX] a short landscape day would stretch its @@ -85,6 +88,13 @@ fun TimelineScale.hourHeight(viewportHeight: Dp): Dp = when (this) { .coerceAtLeast(fillHourHeight(viewportHeight)) } +/** + * The part of a [viewportHeight]-tall timeline the day is fitted to: the + * [FabClearance] under midnight, which lets the last hour scroll clear of the + * FAB (#483), is left out. Pass this, not the raw viewport, to [hourHeight]. + */ +fun dayFitHeight(viewportHeight: Dp): Dp = (viewportHeight - FabClearance).coerceAtLeast(0.dp) + /** * The minute of the day a tap [offsetY] px down a timeline column means, snapped * to the hour it landed in. @@ -105,6 +115,28 @@ fun tappedMinuteOfDay(offsetY: Float, hourPx: Float): Int = */ const val MIN_EVENT_FRACTION = 26f / 60f +/** Where a timed block is drawn down its day, in minutes from midnight. */ +data class RenderedSpan(val topMin: Float, val bottomMin: Float) + +/** + * Where a block of [startMin]..[endMin] is drawn, at least [MIN_EVENT_FRACTION] + * of an hour tall. A short block near midnight rises until that floor ends at + * midnight instead of running off the day, unless it [continuesAfter] into the + * next one (#483). + */ +fun renderedSpan(startMin: Int, endMin: Int, continuesAfter: Boolean): RenderedSpan { + val floor = MIN_EVENT_FRACTION * 60f + val bottom = maxOf(endMin.toFloat(), startMin + floor) + return if (continuesAfter || bottom <= MINUTES_PER_DAY) { + RenderedSpan(startMin.toFloat(), bottom) + } else { + RenderedSpan(MINUTES_PER_DAY - floor, MINUTES_PER_DAY.toFloat()) + } +} + +/** This block's [renderedSpan]. */ +fun TimedBlock.renderedSpan(): RenderedSpan = renderedSpan(startMin, endMin, continuesAfter) + /** Smallest hour height [TimelineScale.FitDay] will resolve to. */ val FIT_DAY_MIN = 24.dp diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScroll.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScroll.kt index 7282db7..f50505a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScroll.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineScroll.kt @@ -32,16 +32,21 @@ val LocalTimelineScroll = staticCompositionLocalOf { null /** A timeline's scroll, and where it opens before it can scroll there. */ @Stable -class TimelineScroll(val state: ScrollState, noonPending: Boolean, private val openingHour: Int?) { +class TimelineScroll( + val state: ScrollState, + noonPending: Boolean, + private val openingHour: Int?, + private val clearancePx: Float = 0f, +) { /** Whether it is still to be put on noon, which it only can be once laid out. */ var noonPending by mutableStateOf(noonPending) internal set - /** The top of what it shows, or will once open, over [content] pixels in a [viewport] pixels high. */ + /** The top of what it shows, or will once open, over [content] pixels of day in a [viewport] pixels high. */ fun top(viewport: Float, content: Float): Float = if (noonPending) { (openingHour?.let { it * content / 24f } ?: (content - viewport) / 2f) - .coerceIn(0f, (content - viewport).coerceAtLeast(0f)) + .coerceIn(0f, (content + clearancePx - viewport).coerceAtLeast(0f)) } else state.value.toFloat() } @@ -58,6 +63,7 @@ fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll { val density = LocalDensity.current val zoom = LocalTimelineZoom.current val topPx = with(density) { timelineTop.toPx() } + val clearancePx = with(density) { FabClearance.roundToPx() } val remembered = memory?.midnightY val scrollState = rememberScrollState( initial = remembered?.let { (topPx - it).roundToInt().coerceAtLeast(0) } ?: 0, @@ -67,6 +73,7 @@ fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll { scrollState, noonPending = remembered == null, openingHour = zoom.hours.takeIf { it.enabled }?.startHour, + clearancePx = clearancePx.toFloat(), ) } val currentTop by rememberUpdatedState(topPx) @@ -80,20 +87,20 @@ fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll { val startHour = zoom.open() if (startHour == null) { snapshotFlow { scrollState.maxValue }.first { it > 0 } - scrollState.scrollTo(scrollState.maxValue / 2) + scrollState.scrollTo(((scrollState.maxValue - clearancePx) / 2).coerceAtLeast(0)) } else { // Fitted to the scroll viewport itself: the layout resolves scales // against the viewport plus the all-day strip, which would push the // end hour below the screen. val viewport = snapshotFlow { scrollState.viewportSize }.first { it > 0 } - val hourHeight = zoom.scale.hourHeight(with(density) { viewport.toDp() }) + val hourHeight = zoom.scale.hourHeight(dayFitHeight(with(density) { viewport.toDp() })) zoom.holdFit(hourHeight) val hourPx = with(density) { hourHeight.toPx() } val target = withTimeoutOrNull(FIT_LAYOUT_TIMEOUT_MILLIS) { snapshotFlow { - startHourScroll(scrollState.maxValue, scrollState.viewportSize, startHour, hourPx) + startHourScroll(scrollState.maxValue, scrollState.viewportSize, startHour, hourPx, clearancePx) }.filterNotNull().first() - } ?: (startHour * (scrollState.maxValue + scrollState.viewportSize) / 24) + } ?: (startHour * (scrollState.maxValue + scrollState.viewportSize - clearancePx) / 24) .coerceIn(0, scrollState.maxValue.coerceAtLeast(0)) scrollState.scrollTo(target) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index d920b15..86e70e5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import de.jeanlucmakiola.calendula.ui.common.orUnnamed import de.jeanlucmakiola.floret.components.IconInfoCard +import de.jeanlucmakiola.floret.components.SegmentedChoiceCard import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme import kotlinx.coroutines.launch import de.jeanlucmakiola.calendula.ui.common.sendEventShare @@ -119,6 +120,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.AccessLevel import de.jeanlucmakiola.calendula.domain.Availability +import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventAttendee import de.jeanlucmakiola.calendula.domain.EventColorOption @@ -892,6 +894,15 @@ private fun EventEditContent( AvailabilityCard(availability = form.availability, onSelect = viewModel::setAvailability) } + OptionalFormSection(visible = EventFormField.Status in state.visibleFields) { + Spacer(Modifier.height(gap)) + StatusCard( + status = form.status, + offerCancelled = state.offerCancelled, + onSelect = viewModel::setStatus, + ) + } + OptionalFormSection(visible = EventFormField.Visibility in state.visibleFields) { Spacer(Modifier.height(gap)) IconInfoCard( @@ -1314,33 +1325,33 @@ private fun RecurrenceCard(state: EventEditUiState, locale: Locale, onClick: (() } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun AvailabilityCard(availability: Availability, onSelect: (Availability) -> Unit) { - IconInfoCard( + SegmentedChoiceCard( icon = Icons.Default.EventAvailable, - iconContentDescription = null, - header = { - Text( - text = stringResource(R.string.event_edit_availability), - style = MaterialTheme.typography.titleMedium, - ) - }, - ) { - Spacer(Modifier.height(8.dp)) - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - SegmentedButton( - selected = availability == Availability.Busy, - onClick = { onSelect(Availability.Busy) }, - shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), - ) { Text(stringResource(R.string.event_availability_busy)) } - SegmentedButton( - selected = availability == Availability.Free, - onClick = { onSelect(Availability.Free) }, - shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), - ) { Text(stringResource(R.string.event_availability_free)) } - } - } + title = stringResource(R.string.event_edit_availability), + options = listOf( + Availability.Busy to stringResource(R.string.event_availability_busy), + Availability.Free to stringResource(R.string.event_availability_free), + ), + selected = availability, + onSelect = onSelect, + ) +} + +@Composable +private fun StatusCard(status: EventStatus, offerCancelled: Boolean, onSelect: (EventStatus) -> Unit) { + SegmentedChoiceCard( + icon = eventFormFieldIcon(EventFormField.Status), + title = stringResource(R.string.event_edit_status), + options = listOfNotNull( + EventStatus.Confirmed to stringResource(R.string.event_status_confirmed), + EventStatus.Tentative to stringResource(R.string.event_status_tentative), + (EventStatus.Cancelled to stringResource(R.string.event_status_cancelled)).takeIf { offerCancelled }, + ), + selected = status, + onSelect = onSelect, + ) } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt index 1be5d3b..f96ee37 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt @@ -48,6 +48,12 @@ data class EventEditUiState( * then drops "only this event" (an exception row can't carry a rule). */ val recurrenceChanged: Boolean = false, + /** + * Whether the Status field offers Cancelled: only for an event that already + * is. Google reads a cancelled event as deleted, so picking it is no safe + * way to strike an event through (#388). + */ + val offerCancelled: Boolean = false, /** * The event-colour palette the resolved target calendar publishes; empty * when it exposes none. Non-empty → the colour picker offers these swatches diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index 8bfddab..5235cb7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -14,8 +14,10 @@ import de.jeanlucmakiola.calendula.data.time.ZoneSource import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultEventDuration import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder +import de.jeanlucmakiola.calendula.domain.canSaveApartFromSeries import de.jeanlucmakiola.calendula.domain.AccessLevel import de.jeanlucmakiola.calendula.domain.Availability +import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EditSnapshot import de.jeanlucmakiola.calendula.domain.EventAttendee @@ -335,6 +337,7 @@ class EventEditViewModel @Inject constructor( // the scope dialog drops "only this event" after a rule change. recurrenceChanged = local.editTarget != null && resolved.rrule != local.editTarget.original.rrule, + offerCancelled = local.editTarget?.original?.status == EventStatus.Cancelled, colorPalette = palette, allowColorOnUnsupportedCalendars = external.allowColorOnUnsupported, ) @@ -671,6 +674,7 @@ class EventEditViewModel @Inject constructor( applyDefaultDuration(id) } fun setAvailability(value: Availability) = edit { it.copy(availability = value) } + fun setStatus(value: EventStatus) = edit { it.copy(status = value) } fun setAccessLevel(value: AccessLevel) = edit { it.copy(accessLevel = value) } /** Pick a palette colour (round-trips via its key); [argb] is its swatch. */ @@ -766,7 +770,10 @@ class EventEditViewModel @Inject constructor( // Managed events are a yearly series whose editable fields (reminders, // notes, location) live on the series row — never offer the scope dialog, // which would split the series into an exception the sync then reverts. - if (target != null && target.original.rrule != null && !current.isManaged && !movingCalendar) { + // A cancelled one only saves as the whole series (#388). + if (target != null && target.original.rrule != null && !current.isManaged && !movingCalendar && + form.canSaveApartFromSeries() + ) { _saveState.value = SaveUiState.AwaitingScope return } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTime.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTime.kt index 8154c25..3ab373b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTime.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTime.kt @@ -2,17 +2,28 @@ package de.jeanlucmakiola.calendula.ui.month import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.coversWholeDays import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay -import de.jeanlucmakiola.calendula.ui.common.rememberInlineTimeWidth +import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel +import de.jeanlucmakiola.calendula.ui.common.rememberWidestTimeSample +import de.jeanlucmakiola.calendula.ui.common.trimmedLines import kotlinx.datetime.TimeZone import java.util.Locale /** * The start time a chip shows before its title (#219), or null when it has none - * to show: an all-day chip never carries a time, and neither does a bar carried - * in from an earlier week, whose start is not in this segment. + * to show: a chip filling whole days never carries a time (#296), and neither + * does a bar carried in from an earlier week, whose start is not in this segment. */ internal fun monthChipTime( event: EventInstance, @@ -21,11 +32,75 @@ internal fun monthChipTime( is24Hour: Boolean, locale: Locale, ): String? { - if (event.isAllDay || continuesLeft) return null + if (event.coversWholeDays(zone) || continuesLeft) return null return formatTimeOfDay(event.start, zone, is24Hour, locale) } -/** The narrowest chip that may show a time — the text it needs, plus its chrome. */ +/** How many characters of a longer title a chip must keep beside its start time (#476). */ +internal const val MONTH_TITLE_MIN_CHARS = 12 + +/** + * The part of [title] that has to stay readable beside a start time: all of it, + * or its first [MONTH_TITLE_MIN_CHARS] characters, counted in code points so an + * emoji is never cut in half. + */ +internal fun monthTitleToKeep(title: String): String { + if (title.codePointCount(0, title.length) <= MONTH_TITLE_MIN_CHARS) return title + return title.substring(0, title.offsetByCodePoints(0, MONTH_TITLE_MIN_CHARS)) +} + +/** + * Whether a chip shows its start time (#219, #476): only with the setting + * [enabled], and only when [availableWidth] (the chip less its chrome) holds + * [labelWidth], the widest time followed by [monthTitleToKeep] of the title. + */ +internal fun monthChipShowsTime( + enabled: Boolean, + availableWidth: Float, + labelWidth: Float, +): Boolean = enabled && labelWidth <= availableWidth + +/** + * Prices month chips' time labels for [monthChipShowsTime] in the chips' own + * text style. One per grid; each title is measured once for its lifetime. + */ +@Stable +internal class MonthChipTimeFit( + private val measurer: TextMeasurer, + private val style: TextStyle, + private val density: Density, + private val timeSample: String, +) { + private val chromePx = with(density) { MONTH_CHIP_CHROME.toPx() } + private val labelWidths = HashMap() + + fun showsTime(chipWidth: Dp, title: String): Boolean { + if (labelWidths.size >= MAX_CACHED_TITLES) labelWidths.clear() + val labelWidth = labelWidths.getOrPut(title) { + val label = inlineTimeLabel(timeSample, monthTitleToKeep(title), Color.Unspecified) + measurer.measure(label, style, softWrap = false, maxLines = 1).size.width + } + return monthChipShowsTime( + enabled = true, + availableWidth = with(density) { chipWidth.toPx() } - chromePx, + labelWidth = labelWidth.toFloat(), + ) + } + + private companion object { + const val MAX_CACHED_TITLES = 1000 + } +} + +/** The grid's [MonthChipTimeFit], or null when start times are switched off (#476). */ @Composable -internal fun rememberMonthTimeChipWidth(): Dp = - rememberInlineTimeWidth(MaterialTheme.typography.labelSmall) + MONTH_CHIP_CHROME +internal fun rememberMonthChipTimeFit(showStartTimes: Boolean): MonthChipTimeFit? { + if (!showStartTimes) return null + val measurer = rememberTextMeasurer(cacheSize = 0) + val style = MaterialTheme.typography.labelSmall.trimmedLines() + val density = LocalDensity.current + val timeSample = rememberWidestTimeSample() + return remember(measurer, style, density, timeSample) { + MonthChipTimeFit(measurer, style, density, timeSample) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt index e46e36b..08e867c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthMorph.kt @@ -34,6 +34,10 @@ internal sealed interface MonthMorphKey { data class DayNumber(val date: LocalDate) : MonthMorphKey + data class WeekNumber(val weekStart: LocalDate) : MonthMorphKey + + data class CellRipple(val date: LocalDate) : MonthMorphKey + /** * One event *on one day*. Keyed by date as well as instance because a * multi-day event has a dot on every day it covers but only one bar, drawn @@ -111,8 +115,8 @@ internal fun morphInFlight(): Boolean { * * Rendering in place puts everything back in one z-order and one clip, which is * what makes the grid read as a single surface changing shape rather than a - * stack of pieces sliding past each other. Nothing here needs to escape its - * ancestors: the cells, marks and markers all travel within the grid. + * stack of pieces sliding past each other. Event marks are the exception: they + * render in the overlay so the expanding day backgrounds cannot cover them. */ @OptIn(ExperimentalSharedTransitionApi::class) @Composable @@ -141,6 +145,7 @@ internal fun Modifier.morphElement(key: MonthMorphKey): Modifier { @Composable internal fun Modifier.morphBounds(key: MonthMorphKey): Modifier { val morph = LocalMonthMorph.current ?: return this + val isEvent = key is MonthMorphKey.Event return with(morph.shared) { this@morphBounds.sharedBounds( sharedContentState = rememberSharedContentState(key), @@ -148,7 +153,8 @@ internal fun Modifier.morphBounds(key: MonthMorphKey): Modifier { enter = fadeIn(), exit = fadeOut(), resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds, - renderInOverlayDuringTransition = false, + renderInOverlayDuringTransition = isEvent, + zIndexInOverlay = if (isEvent) 1f else 0f, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index aebaf26..2115f57 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -2,7 +2,12 @@ package de.jeanlucmakiola.calendula.ui.month import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.indication +import androidx.compose.foundation.LocalIndication import androidx.compose.animation.EnterExitState +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.pager.HorizontalPager @@ -11,8 +16,8 @@ import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.setValue import androidx.compose.runtime.rememberUpdatedState -import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay import de.jeanlucmakiola.calendula.ui.agenda.agendaDays +import de.jeanlucmakiola.calendula.ui.common.FabClearance import de.jeanlucmakiola.calendula.ui.common.PublishChrome import de.jeanlucmakiola.calendula.ui.common.ScreenActions import de.jeanlucmakiola.calendula.ui.common.ScreenBody @@ -278,6 +283,8 @@ fun MonthScreen( val selectedDate by viewModel.selectedDate.collectAsStateWithLifecycle() val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle() val showWeekNumbers by viewModel.showWeekNumbers.collectAsStateWithLifecycle() + val showToday by viewModel.showToday.collectAsStateWithLifecycle() + val showStartTimes by viewModel.showStartTimes.collectAsStateWithLifecycle() // The instant before which an event counts as completed, or null when dimming // is off. derivedStateOf keeps the per-minute "now" from recomposing the // screen while the setting is off (it stays null regardless of the tick). @@ -363,7 +370,7 @@ fun MonthScreen( if (!scrolling) return@LaunchedEffect snapshotFlow { // Empty before the first layout pass — and reporting a default of 0 - // for it would claim January 1900 is on screen and drag the whole + // for it would claim the span's first month is on screen and drag the whole // window back there. val visible = listState.layoutInfo.visibleItemsInfo when { @@ -448,7 +455,8 @@ fun MonthScreen( } } // Drawer jump-to-date. - val jumpToDate: (LocalDate) -> Unit = { target -> + val jumpToDate: (LocalDate) -> Unit = { date -> + val target = coerceToMonthRange(date) if (scrolling) { val targetMonth = target.yearMonth pendingScrollMonth = targetMonth @@ -524,17 +532,20 @@ fun MonthScreen( ) { if (scrolling) { ContinuousMonthContent( - state = continuousState, + // Held on the skeleton until the start-time setting has + // loaded, so chips don't redraw without their times. + state = if (showStartTimes == null) ContinuousMonthUiState.Loading else continuousState, listState = listState, dense = dense, showWeekNumbers = showWeekNumbers, + showStartTimes = showStartTimes == true, onRetry = jumpToToday, onOpenDay = onOpenDay, onEventClick = onEventClick, ) } else { SplitMonthContent( - state = state, + state = if (showStartTimes == null) MonthUiState.Loading else state, selected = selectedDate, initiallyExpanded = viewStyle == MonthViewStyle.Paged, expanded = splitExpanded, @@ -543,6 +554,8 @@ fun MonthScreen( weekStart = weekStart, monthState = viewModel::month, showWeekNumbers = showWeekNumbers, + showStartTimes = showStartTimes == true, + showToday = showToday, onPaneSwipeNext = { viewModel.stepSplitPane(1) }, onPaneSwipePrev = { viewModel.stepSplitPane(-1) }, onClearSelection = viewModel::clearSelection, @@ -551,8 +564,10 @@ fun MonthScreen( // month, pager and all — straight away, or a swipe // still settling would land and take the tap back. onSelectDay = { date -> - viewModel.selectDate(date) - jumpToMonth(date.yearMonth) + if (isInMonthRange(date)) { + viewModel.selectDate(date) + jumpToMonth(date.yearMonth) + } }, onOpenDay = onOpenDay, onEventClick = onEventClick, @@ -561,7 +576,7 @@ fun MonthScreen( } } } - MonthDragOverlay(chipDrag) + MonthDragOverlay(chipDrag, showStartTimes = showStartTimes == true) } } } @@ -574,11 +589,11 @@ private const val MONTH_SETTLE_TIMEOUT_MILLIS = 450L /** The chip in flight, drawn over the grid and following the finger. */ @Composable -private fun MonthDragOverlay(controller: MonthDragController) { +private fun MonthDragOverlay(controller: MonthDragController, showStartTimes: Boolean) { var origin by remember { mutableStateOf(Offset.Zero) } val dark = LocalFloretDarkTheme.current val density = LocalDensity.current - val timeChipWidth = rememberMonthTimeChipWidth() + val timeFit = rememberMonthChipTimeFit(showStartTimes) val reduceMotion = rememberReduceMotion() val moveInFlight = moveInFlight() // Here rather than beside the controller: this reads [drag], which changes @@ -640,7 +655,8 @@ private fun MonthDragOverlay(controller: MonthDragController) { // cut-out of it has the room — which a slice of a multi-day bar has // not, however wide the bar was. time = drag.time, - showTime = with(density) { drag.sizePx.width.toDp() } >= timeChipWidth, + timeFit = timeFit, + chipWidth = with(density) { drag.sizePx.width.toDp() }, modifier = Modifier // Absolute: these are root coordinates, and the direction-aware // offset would mirror them across the screen in an RTL layout. @@ -677,6 +693,7 @@ private fun ContinuousMonthContent( listState: LazyListState, dense: Boolean, showWeekNumbers: Boolean, + showStartTimes: Boolean, onRetry: () -> Unit, onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, @@ -693,6 +710,7 @@ private fun ContinuousMonthContent( state = state, listState = listState, showWeekNumbers = showWeekNumbers, + showStartTimes = showStartTimes, onOpenDay = onOpenDay, onEventClick = onEventClick, ) @@ -701,6 +719,7 @@ private fun ContinuousMonthContent( state = state, listState = listState, showWeekNumbers = showWeekNumbers, + showStartTimes = showStartTimes, onOpenDay = onOpenDay, onEventClick = onEventClick, ) @@ -873,6 +892,7 @@ private val DENSE_HEADER_GAP = 4.dp internal fun MonthGrid( state: MonthUiState.Success, showWeekNumbers: Boolean, + showStartTimes: Boolean, onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, /** See [MonthWeekRow]'s `selected`: an anchor for the morph, never a mark. */ @@ -897,13 +917,13 @@ internal fun MonthGrid( val month = state.month // Once per grid: the value depends on the typography, the density, the // locale and the 24-hour setting, none of which vary by row (#219). - val timeChipWidth = rememberMonthTimeChipWidth() + val timeFit = rememberMonthChipTimeFit(showStartTimes) state.weeks.forEach { week -> MonthWeekRow( week = week, today = state.today, zone = state.zone, - timeChipWidth = timeChipWidth, + timeFit = timeFit, rowHeight = rowHeight, inMonth = { it.month == month.month && it.year == month.year }, showWeekNumbers = showWeekNumbers, @@ -939,18 +959,19 @@ internal fun ContinuousMonthGrid( state: ContinuousMonthUiState.Success, listState: LazyListState, showWeekNumbers: Boolean, + showStartTimes: Boolean, onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, modifier: Modifier = Modifier, ) { val monthCount = remember { continuousMonthCount() } val todayMonth = remember(state.today) { state.today.yearMonth } - val timeChipWidth = rememberMonthTimeChipWidth() + val timeFit = rememberMonthChipTimeFit(showStartTimes) LazyColumn( state = listState, modifier = modifier.fillMaxSize(), // Bottom inset clears the FAB stack so the last row stays tappable. - contentPadding = PaddingValues(bottom = 96.dp), + contentPadding = PaddingValues(bottom = FabClearance), ) { repeat(monthCount) { index -> val month = yearMonthForIndex(index) @@ -968,7 +989,7 @@ internal fun ContinuousMonthGrid( weekStart = state.weekStart, today = state.today, zone = state.zone, - timeChipWidth = timeChipWidth, + timeFit = timeFit, showWeekNumbers = showWeekNumbers, onOpenDay = onOpenDay, onEventClick = onEventClick, @@ -990,7 +1011,7 @@ private fun ContinuousMonthBlock( weekStart: DayOfWeek, today: LocalDate, zone: TimeZone, - timeChipWidth: Dp, + timeFit: MonthChipTimeFit?, showWeekNumbers: Boolean, onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, @@ -1011,7 +1032,7 @@ private fun ContinuousMonthBlock( week = week, today = today, zone = zone, - timeChipWidth = timeChipWidth, + timeFit = timeFit, rowHeight = CONTINUOUS_ROW_HEIGHT, inMonth = { it.month == month.month && it.year == month.year }, // The block owns its month alone: a day from either @@ -1077,12 +1098,13 @@ internal fun DenseMonthGrid( state: ContinuousMonthUiState.Success, listState: LazyListState, showWeekNumbers: Boolean, + showStartTimes: Boolean, onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, modifier: Modifier = Modifier, ) { val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) } - val timeChipWidth = rememberMonthTimeChipWidth() + val timeFit = rememberMonthChipTimeFit(showStartTimes) LazyColumn( state = listState, modifier = modifier @@ -1095,7 +1117,7 @@ internal fun DenseMonthGrid( .padding(top = DENSE_HEADER_GAP), verticalArrangement = Arrangement.spacedBy(2.dp), // Bottom inset clears the FAB stack so the last row stays tappable. - contentPadding = PaddingValues(bottom = 96.dp), + contentPadding = PaddingValues(bottom = FabClearance), ) { items(count = weekCount, key = { it }) { index -> val week = state.weeksByIndex[index] @@ -1106,7 +1128,7 @@ internal fun DenseMonthGrid( week = week, today = state.today, zone = state.zone, - timeChipWidth = timeChipWidth, + timeFit = timeFit, rowHeight = CONTINUOUS_ROW_HEIGHT, // Every day in the stream belongs to a month equally — there // is no "other month" to recede here. @@ -1198,6 +1220,8 @@ private fun SplitMonthContent( weekStart: DayOfWeek, monthState: (YearMonth, DayOfWeek) -> StateFlow, showWeekNumbers: Boolean, + showStartTimes: Boolean, + showToday: Boolean, onPaneSwipeNext: () -> Unit, onPaneSwipePrev: () -> Unit, onClearSelection: () -> Unit, @@ -1207,9 +1231,12 @@ private fun SplitMonthContent( onEventClick: (EventInstance) -> Unit, onCreateEvent: (LocalDate) -> Unit, ) { - val fadeSpec = rememberCalendarFadeSpec() val reduceMotion = rememberReduceMotion() val haptics = LocalHapticFeedback.current + // Registered first so the expand handler below wins while the grid is open. + BackHandler(enabled = selected != null && !expanded) { + onClearSelection() + } BackHandler(enabled = expanded != initiallyExpanded) { onExpandedChange(initiallyExpanded) } @@ -1278,8 +1305,9 @@ private fun SplitMonthContent( modifier = Modifier.fillMaxSize(), // Both faces fill the same page — the grid grows into exactly the // room the pane gives up — so there is no size change to contain. - // The travel between them is the shared elements' job. - transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) }, + // Fading the faces made the shared cell backgrounds briefly + // disappear beneath their incoming counterparts. + transitionSpec = { EnterTransition.None togetherWith ExitTransition.None }, label = "split-expand", ) { isExpanded -> // The view switch's morph stays off a face until it has fully @@ -1310,6 +1338,7 @@ private fun SplitMonthContent( state = page, selected = pageSelected, showWeekNumbers = showWeekNumbers, + showStartTimes = showStartTimes, expandDrag = expandDrag, onPickDay = onPickDay, // A tapped chip is asking for that event, not for its @@ -1335,7 +1364,11 @@ private fun SplitMonthContent( state = page, paneReady = paneReady, selected = pageSelected, + selectionIndicatorVisible = reduceMotion || + (transition.currentState == EnterExitState.Visible && + transition.targetState == EnterExitState.Visible), showWeekNumbers = showWeekNumbers, + showToday = showToday, expandDrag = expandDrag, onSelectDay = onSelectDay, onPaneSwipeNext = { @@ -1377,7 +1410,9 @@ private fun SplitMonthCollapsed( state: MonthUiState.Success, paneReady: Boolean, selected: LocalDate?, + selectionIndicatorVisible: Boolean, showWeekNumbers: Boolean, + showToday: Boolean, expandDrag: Modifier, onSelectDay: (LocalDate) -> Unit, onPaneSwipeNext: () -> Unit, @@ -1397,6 +1432,7 @@ private fun SplitMonthCollapsed( SplitMonthGrid( state = state, selected = selected, + selectionIndicatorVisible = selectionIndicatorVisible, showWeekNumbers = showWeekNumbers, onSelectDay = onSelectDay, modifier = Modifier.height(splitGridHeight(state.weeks.size)), @@ -1410,6 +1446,7 @@ private fun SplitMonthCollapsed( Box(Modifier.weight(1f).fillMaxWidth().then(paneModifier)) { SplitMonthAgendaPane( state = state, + showToday = showToday, onOpenDay = onOpenDay, onEventClick = onEventClick, onCreateEvent = onCreateEvent, @@ -1476,6 +1513,7 @@ private fun SplitMonthExpanded( /** Marked nowhere here; it only anchors the outline's morph. */ selected: LocalDate?, showWeekNumbers: Boolean, + showStartTimes: Boolean, expandDrag: Modifier, onPickDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, @@ -1488,6 +1526,7 @@ private fun SplitMonthExpanded( MonthGrid( state = state, showWeekNumbers = showWeekNumbers, + showStartTimes = showStartTimes, onOpenDay = onPickDay, onEventClick = onEventClick, selected = selected, @@ -1601,6 +1640,7 @@ private val SPLIT_PANE_EASING = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f) internal fun SplitMonthGrid( state: MonthUiState.Success, selected: LocalDate?, + selectionIndicatorVisible: Boolean = true, showWeekNumbers: Boolean, onSelectDay: (LocalDate) -> Unit, modifier: Modifier = Modifier, @@ -1646,6 +1686,7 @@ internal fun SplitMonthGrid( // removes that without timing anything against the // page transition. isSelected = day == selected && inMonth, + selectionIndicatorVisible = selectionIndicatorVisible, inMonth = inMonth, relativeToToday = todayCol?.let { col - it }, rippleProgress = { ripple.value }, @@ -1674,6 +1715,7 @@ private fun SplitDayCell( hidden: List, isToday: Boolean, isSelected: Boolean, + selectionIndicatorVisible: Boolean, inMonth: Boolean, relativeToToday: Int?, rippleProgress: () -> Float, @@ -1703,7 +1745,7 @@ private fun SplitDayCell( // pace it lingered after the thing it marks had already moved. val outlineSpec = MaterialTheme.motionScheme.fastEffectsSpec() val selection = animateFloatAsState( - targetValue = if (isSelected) 1f else 0f, + targetValue = if (isSelected && selectionIndicatorVisible) 1f else 0f, animationSpec = if (reduceMotion) snap() else outlineSpec, label = "split-day-selection", ) @@ -1715,12 +1757,19 @@ private fun SplitDayCell( // built — and so the pill can be tagged for the morph on its own. Tagged as // one piece with its contents inside, the dots would be nested shared // elements within a shared element and travel twice. + // The cell keeps the click and selection semantics, so TalkBack reads the + // day number with them; the topmost layer only draws the ripple. + val interaction = remember { MutableInteractionSource() } Box( modifier = modifier .padding(horizontal = CELL_GAP, vertical = 1.dp) .todayRipple(relativeToToday, rippleProgress) - .clip(CELL_SHAPE) - .selectable(selected = isSelected, onClick = onClick), + .selectable( + selected = isSelected, + interactionSource = interaction, + indication = null, + onClick = onClick, + ), contentAlignment = Alignment.TopCenter, ) { Box( @@ -1782,6 +1831,13 @@ private fun SplitDayCell( Spacer(Modifier.height(2.dp)) SplitDots(date = date, events = events, hidden = hidden, dark = dark) } + Box( + Modifier + .fillMaxSize() + .morphElement(MonthMorphKey.CellRipple(date)) + .clip(CELL_SHAPE) + .indication(interaction, LocalIndication.current), + ) } } @@ -1852,22 +1908,33 @@ private fun SplitDots( @Composable private fun SplitMonthAgendaPane( state: MonthUiState.Success, + showToday: Boolean, onOpenDay: (LocalDate) -> Unit, onEventClick: (EventInstance) -> Unit, onCreateEvent: (LocalDate) -> Unit, ) { - val start = agendaStartForMonth(state.month, state.today) - val days = state.instancesByDay.keys - .filter { it >= start && it.year == state.month.year && it.month == state.month.month } - .filter { state.instancesByDay[it].orEmpty().isNotEmpty() } - .toMutableList() - .apply { if (start !in this) add(start) } - .sorted() + val days = remember(state.month, state.today, state.instancesByDay, showToday) { + splitAgendaDays(state.month, state.today, state.instancesByDay, showToday) + } val dimCutoff = LocalDimCutoff.current val listState = key(state.month) { rememberLazyListState() } - LazyColumn(state = listState, contentPadding = PaddingValues(top = 4.dp, bottom = 96.dp)) { + LazyColumn(state = listState, contentPadding = PaddingValues(bottom = FabClearance)) { + // A fixed first item, so a day added above the first listed one shows + // up rather than landing above a list that holds its scroll position. + item(key = "top") { Spacer(Modifier.height(4.dp)) } + if (days.isEmpty()) { + val start = agendaStartForMonth(state.month, state.today) + item(key = "empty") { + AgendaEmptyDayRow( + text = stringResource( + if (start == state.today) R.string.month_split_no_more_events else R.string.month_split_no_events_month, + ), + onClick = { onCreateEvent(start) }, + ) + } + } agendaDays( - days = days.map { AgendaDay(it, state.instancesByDay[it].orEmpty()) }, + days = days, today = state.today, zone = state.zone, emptyText = R.string.month_split_no_events, @@ -1945,7 +2012,7 @@ internal fun SplitDayPane( else -> LazyColumn( // Bottom inset clears the FAB stack so the last row stays // tappable. - contentPadding = PaddingValues(bottom = 96.dp), + contentPadding = PaddingValues(bottom = FabClearance), ) { itemsIndexed( items = dayEvents, @@ -2101,8 +2168,8 @@ private fun MonthWeekRow( today: LocalDate, /** The zone the grid was laid out in — never re-read here (see [MonthUiState.Success.zone]). */ zone: TimeZone, - /** The narrowest chip that may carry a start time, measured once per grid (#219). */ - timeChipWidth: Dp, + /** Prices chips' start times, once per grid; null when they are switched off (#219, #476). */ + timeFit: MonthChipTimeFit?, /** The height this row was given, which decides its lanes — see [laneCapFor]. */ rowHeight: Dp, inMonth: (LocalDate) -> Boolean, @@ -2228,8 +2295,8 @@ private fun MonthWeekRow( ), ), ) { - // What a chip has to spend, against the [timeChipWidth] a start time - // costs it (#219). + // What a chip has to spend, against what [timeFit] says its start + // time costs it (#219). val colW = maxWidth / 7 // Held here rather than read at the offset: the dots are placed // inside a plain lambda, which is no longer in this scope. The box @@ -2319,7 +2386,8 @@ private fun MonthWeekRow( continuesRight = span.continuesRight, days = week.days.subList(span.startCol, span.endCol + 1), time = chipTimes[span.event.instanceId], - showTime = colW * cols >= timeChipWidth, + timeFit = timeFit, + chipWidth = colW * cols, modifier = Modifier .offset( x = colW * span.startCol, @@ -2396,7 +2464,8 @@ private fun MonthWeekRow( continuesRight = false, days = listOf(d), time = chipTimes[ev.instanceId], - showTime = colW >= timeChipWidth, + timeFit = timeFit, + chipWidth = colW, modifier = Modifier .offset( x = colW * col, @@ -2494,6 +2563,7 @@ private fun MonthWeekRow( } } .padding(horizontal = CELL_GAP, vertical = 1.dp) + .morphElement(MonthMorphKey.CellRipple(d)) .clip(CELL_SHAPE) .clickable { // Cleared on read: a click with no fresh down @@ -2644,6 +2714,7 @@ private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier Box( modifier = modifier .padding(horizontal = CELL_GAP, vertical = 1.dp) + .morphElement(MonthMorphKey.WeekNumber(weekStart)) .clip(CELL_SHAPE) .background(MaterialTheme.colorScheme.secondaryContainer) .then(if (onOpenWeek != null) Modifier.clickable(onClickLabel = openLabel) { @@ -2722,7 +2793,7 @@ private fun shortMonthName(date: LocalDate): String { /** * A filled event pill/bar — softened (or raw) fill, title clipped to one line, - * with the start time before it where the chip is wide enough ([showTime], #219). + * with the start time before it where the chip is wide enough ([timeFit], #219, #476). */ @Composable private fun MonthBar( @@ -2739,10 +2810,13 @@ private fun MonthBar( days: List? = null, /** The chip's start time, or null where it has none — see `monthChipTime` (#219). */ time: String? = null, - /** Whether this chip has the width to draw [time]; a screen reader gets it either way. */ - showTime: Boolean = false, + /** Decides whether [time] is drawn at [chipWidth]; null draws none. A screen reader gets it either way. */ + timeFit: MonthChipTimeFit? = null, + /** The chip's full width, chrome included. */ + chipWidth: Dp = 0.dp, ) { val title = event.title.ifBlank { stringResource(R.string.event_untitled) } + val showTime = time != null && timeFit != null && timeFit.showsTime(chipWidth, title) val dimCutoff = LocalDimCutoff.current // A bar is one piece, so it dims once the last day it covers has passed. val dimmed = dimCutoff != null && diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt index 136af15..ac4e880 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthStylePreview.kt @@ -31,6 +31,7 @@ import kotlinx.datetime.yearMonth internal fun MonthStylePreview( style: MonthViewStyle, weekStart: DayOfWeek, + showStartTimes: Boolean, height: Dp, modifier: Modifier = Modifier, ) { @@ -44,6 +45,7 @@ internal fun MonthStylePreview( MonthViewStyle.Paged -> MonthGrid( state = sample.month, showWeekNumbers = false, + showStartTimes = showStartTimes, onOpenDay = {}, onEventClick = {}, ) @@ -55,6 +57,7 @@ internal fun MonthStylePreview( ), ), showWeekNumbers = false, + showStartTimes = showStartTimes, onOpenDay = {}, onEventClick = {}, ) @@ -67,6 +70,7 @@ internal fun MonthStylePreview( initialFirstVisibleItemIndex = weekIndexOf(today, weekStart) - 1, ), showWeekNumbers = false, + showStartTimes = showStartTimes, onOpenDay = {}, onEventClick = {}, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt index e84d382..8f83b13 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt @@ -13,11 +13,15 @@ import de.jeanlucmakiola.calendula.data.time.currentDate import de.jeanlucmakiola.calendula.data.time.stateFollowingDays import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.coversWholeDays import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.calendarListFailure +import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay +import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing +import de.jeanlucmakiola.calendula.ui.common.CalendarSpan import de.jeanlucmakiola.calendula.ui.common.PageStateCache -import de.jeanlucmakiola.calendula.domain.isDeclined -import de.jeanlucmakiola.calendula.domain.eventDisplayComparator +import de.jeanlucmakiola.calendula.domain.isCrossedOut +import de.jeanlucmakiola.calendula.domain.sortedForDisplay import de.jeanlucmakiola.calendula.ui.week.coversDay import de.jeanlucmakiola.calendula.ui.week.layoutAllDay import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays @@ -81,6 +85,23 @@ class MonthViewModel @Inject constructor( initialValue = false, ) + /** Whether the split agenda keeps a free today, as the Agenda view does (#35). */ + val showToday: StateFlow = settingsPrefs.agendaShowToday + .stateWhileSubscribed( + scope = viewModelScope, + initialValue = true, + ) + + /** + * Whether chips may show their start time before the title (#476; display + * only). Null until the setting has loaded, so the grid can wait for it. + */ + val showStartTimes: StateFlow = settingsPrefs.monthShowStartTimes + .stateWhileSubscribed( + scope = viewModelScope, + initialValue = null, + ) + /** How the grid is laid out and navigated (#38, #53). */ val viewStyle: StateFlow = settingsPrefs.monthViewStyle .stateWhileSubscribed( @@ -95,7 +116,7 @@ class MonthViewModel @Inject constructor( val today: StateFlow = todaySource.today.stateFollowingDays(viewModelScope, todayDate, ::followToday) - private val _month = MutableStateFlow(todayDate.yearMonth) + private val _month = MutableStateFlow(yearMonthForIndex(monthIndexOf(todayDate.yearMonth))) val month: StateFlow = _month /** @@ -149,7 +170,7 @@ class MonthViewModel @Inject constructor( /** The pager came to rest on [page]; follow it unless it is already the anchor's. */ fun onPageSettled(page: Int) { if (monthIndexOf(_month.value) == page) return - _month.value = yearMonthForIndex(page) + setMonth(yearMonthForIndex(page)) // Moving the grid to another month restores its full agenda. _selectedDate.value = null } @@ -298,9 +319,11 @@ class MonthViewModel @Inject constructor( * silently list a date the grid isn't pointing at. */ fun selectDate(date: LocalDate) { + // A greyed-out day past either end of the grid has no month to follow to. + if (!isInMonthRange(date)) return val target = date.yearMonth _selectedDate.value = if (target == _month.value && _selectedDate.value == date) null else date - if (target != _month.value) _month.value = target + if (target != _month.value) setMonth(target) } fun clearSelection() { _selectedDate.value = null } @@ -309,16 +332,16 @@ class MonthViewModel @Inject constructor( fun stepSplitPane(days: Int) { val selected = _selectedDate.value if (selected == null) { - _month.value = yearMonthForIndex(monthIndexOf(_month.value) + days) + setMonth(yearMonthForIndex(monthIndexOf(_month.value) + days)) } else { - val next = selected.plus(days, DateTimeUnit.DAY) + val next = coerceToMonthRange(selected.plus(days, DateTimeUnit.DAY)) _selectedDate.value = next - _month.value = next.yearMonth + setMonth(next.yearMonth) } } fun goToToday() { - _month.value = todayDate.yearMonth + setMonth(todayDate.yearMonth) _selectedDate.value = null } @@ -330,7 +353,7 @@ class MonthViewModel @Inject constructor( private fun followToday(old: LocalDate, new: LocalDate) { if (viewStyle.value.isScrolling) return if (_month.value != old.yearMonth) return - _month.value = new.yearMonth + setMonth(new.yearMonth) _selectedDate.value = null } @@ -344,14 +367,24 @@ class MonthViewModel @Inject constructor( */ fun syncScrollMonth(ym: YearMonth) { if (_month.value == ym) return - _month.value = ym + setMonth(ym) _selectedDate.value = null } - /** Jump to the month containing [date] (drawer jump-to-date). */ + /** + * Jump to the month containing [date] (drawer jump-to-date, or the focus + * handed over by another view). The focus itself keeps a date outside the + * grid's span, so the day and week views return to it unchanged. + */ fun goToDate(date: LocalDate) { - _month.value = date.yearMonth - _selectedDate.value = date + val target = coerceToMonthRange(date) + setMonth(target.yearMonth) + _selectedDate.value = target + } + + /** Every write to [_month] goes through here, so it can't leave the grid's span (#457). */ + private fun setMonth(ym: YearMonth) { + _month.value = yearMonthForIndex(monthIndexOf(ym)) } private fun buildState( @@ -469,7 +502,7 @@ internal fun layoutCalendarWeek( // crossing midnight into the next week row covers a single day in each, and // counting per row drew it as two unrelated pills (#253). val (bars, singles) = weekEvents.partition { ev -> - ev.isAllDay || ev.spansMultipleDays(zone) + ev.coversWholeDays(zone) || ev.spansMultipleDays(zone) } val spans = layoutAllDay(bars, days, zone).map { s -> MonthSpan( @@ -488,7 +521,7 @@ internal fun layoutCalendarWeek( // Declined last, so a day that overflows drops the events you said // no to before the ones you are actually going to (#230). singles.filter { it.coversDay(d, zone) } - .sortedWith(compareBy { it.isDeclined }.thenBy { it.start }) + .sortedWith(compareBy { it.isCrossedOut }.thenBy { it.start }) }, countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } }, ) @@ -509,7 +542,7 @@ internal fun instancesByDay( days.associateWith { day -> instances .filter { it.coversDay(day, zone) } - .sortedWith(eventDisplayComparator(zone)) + .sortedForDisplay(zone) } /** @@ -556,6 +589,25 @@ internal fun agendaStartForMonth(month: YearMonth, today: LocalDate): LocalDate LocalDate(month.year, month.month, 1) } +/** + * The days the split agenda lists for [month]: those from [agendaStartForMonth] + * on that have events, plus a free today when [showToday] is set, as in the + * Agenda view (#142). + */ +internal fun splitAgendaDays( + month: YearMonth, + today: LocalDate, + instancesByDay: Map>, + showToday: Boolean, +): List { + val start = agendaStartForMonth(month, today) + val days = instancesByDay.keys + .filter { it >= start && it.yearMonth == month && instancesByDay.getValue(it).isNotEmpty() } + .sorted() + .map { AgendaDay(it, instancesByDay.getValue(it)) } + return anchorTodayIfMissing(days, today, enabled = showToday && start == today) +} + /** * The window to load for a visible range, or null to keep the current one. * @@ -578,24 +630,39 @@ internal fun nextLoadWindow( /** * The continuous grid addresses months by an absolute index, so the list has one * stable, gap-free coordinate space to scroll through and key its items by. - * Index 0 is January 1900; the list runs to [continuousMonthCount]. + * Index 0 is the first month of [CalendarSpan]; the list runs to [continuousMonthCount]. * * Unlike the week indexing this replaced, the coordinate space doesn't depend on * the week-start preference — changing it reflows the rows *inside* a month * block but never moves the block, so a scroll position stays put. */ -private const val MONTH_INDEX_EPOCH_YEAR = 1900 -private const val MONTH_INDEX_END_YEAR = 2100 +private val MONTH_INDEX_EPOCH_YEAR = CalendarSpan.FIRST_DAY.year +private val MONTH_INDEX_END_YEAR = CalendarSpan.LAST_DAY.year + +/** The first and last day the month and Dense grids can show. */ +private val GRID_FIRST_DAY = LocalDate(MONTH_INDEX_EPOCH_YEAR, 1, 1) +private val GRID_LAST_DAY = LocalDate(MONTH_INDEX_END_YEAR, 12, 31) internal fun monthIndexOf(ym: YearMonth): Int = (ym.year - MONTH_INDEX_EPOCH_YEAR) * 12 + ym.month.ordinal -internal fun yearMonthForIndex(index: Int): YearMonth = YearMonth( - MONTH_INDEX_EPOCH_YEAR + index / 12, - Month.entries[index % 12], -) +/** The month at [index], held to the grid's span, so a stray index can't throw (#457). */ +internal fun yearMonthForIndex(index: Int): YearMonth { + val held = clampMonthIndex(index) + return YearMonth(MONTH_INDEX_EPOCH_YEAR + held / 12, Month.entries[held % 12]) +} -/** Total months the continuous grid scrolls through (1900 → 2100). */ +internal fun clampMonthIndex(index: Int): Int = index.coerceIn(0, continuousMonthCount() - 1) + +/** + * Hold [date] inside the span the month grid can show. Every view shares + * [CalendarSpan], but another app can open Calendula on any date (#457). + */ +internal fun coerceToMonthRange(date: LocalDate): LocalDate = date.coerceIn(GRID_FIRST_DAY, GRID_LAST_DAY) + +internal fun isInMonthRange(date: LocalDate): Boolean = date in GRID_FIRST_DAY..GRID_LAST_DAY + +/** Total months the continuous grid scrolls through. */ internal fun continuousMonthCount(): Int = (MONTH_INDEX_END_YEAR - MONTH_INDEX_EPOCH_YEAR + 1) * 12 @@ -611,25 +678,23 @@ internal fun monthIndexForItem(itemIndex: Int): Int = itemIndex / 2 /** * Hold a load window inside the months that actually exist. The pad * [nextLoadWindow] adds can otherwise push an index past either end of the - * 1900–2100 span, and [yearMonthForIndex] has no month to hand back for one. + * span, and [yearMonthForIndex] has no month to hand back for one. */ -internal fun clampMonthWindow(window: IntRange): IntRange { - val last = continuousMonthCount() - 1 - return window.first.coerceIn(0, last)..window.last.coerceIn(0, last) -} +internal fun clampMonthWindow(window: IntRange): IntRange = + clampMonthIndex(window.first)..clampMonthIndex(window.last) /** * The Dense style addresses *weeks* by absolute index instead: it has no month * blocks to key by, and a row's identity has to survive the months around it - * scrolling past. Index 0 is the first week of 1900 under the active week-start, + * scrolling past. Index 0 is the span's first week under the active week-start, * so — like the month space — every index is non-negative and the LazyColumn's * item indices and week indices are the same number. * * Unlike month indices these *do* shift with the week-start preference, which is * why the list state is keyed on it. */ -private val WEEK_INDEX_EPOCH = LocalDate(1900, 1, 1) -private val WEEK_INDEX_END = LocalDate(2100, 12, 31) +private val WEEK_INDEX_EPOCH = GRID_FIRST_DAY +private val WEEK_INDEX_END = GRID_LAST_DAY internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int { val base = WEEK_INDEX_EPOCH.startOfWeek(weekStart) @@ -639,7 +704,7 @@ internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int { internal fun weekStartForIndex(index: Int, weekStart: DayOfWeek): LocalDate = WEEK_INDEX_EPOCH.startOfWeek(weekStart).plus(index * 7, DateTimeUnit.DAY) -/** Total weeks the Dense grid scrolls through (1900 → 2100). */ +/** Total weeks the Dense grid scrolls through. */ internal fun continuousWeekCount(weekStart: DayOfWeek): Int = weekIndexOf(WEEK_INDEX_END, weekStart) + 1 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt index 9407aa9..2a2894d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/OnboardingViewModel.kt @@ -220,6 +220,7 @@ class OnboardingViewModel @Inject constructor( prefs.defaultView, prefs.monthViewStyle, prefs.weekStart, + prefs.monthShowStartTimes, ::ViewChoice, ).stateWhileSubscribed( scope = viewModelScope, @@ -464,6 +465,7 @@ data class ViewChoice( val defaultView: CalendarView, val monthViewStyle: MonthViewStyle, val weekStart: WeekStartPref, + val monthShowStartTimes: Boolean, ) /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt index 1d512e2..e955340 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/onboarding/ViewStep.kt @@ -63,6 +63,7 @@ internal fun ViewStep( CalendarView.Month -> MonthStylePreview( style = choice.monthViewStyle, weekStart = weekStart, + showStartTimes = choice.monthShowStartTimes, height = PREVIEW_HEIGHT, ) CalendarView.Week -> WeekViewPreview(weekStart = weekStart, height = PREVIEW_HEIGHT) @@ -118,7 +119,12 @@ internal fun MonthStyleStep( navigationIcon = navigationIcon, ) { StepPreview(selected = choice.monthViewStyle) { style -> - MonthStylePreview(style = style, weekStart = weekStart, height = PREVIEW_HEIGHT) + MonthStylePreview( + style = style, + weekStart = weekStart, + showStartTimes = choice.monthShowStartTimes, + height = PREVIEW_HEIGHT, + ) } GroupedHint(stringResource(choice.monthViewStyle.descriptionRes), verticalPadding = 8.dp) options.forEachIndexed { index, style -> diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt index 58f39a5..018cf5e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt @@ -75,7 +75,7 @@ import de.jeanlucmakiola.calendula.domain.MatchSpan import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.SearchHit import de.jeanlucmakiola.calendula.domain.SearchMonth -import de.jeanlucmakiola.calendula.domain.isDeclined +import de.jeanlucmakiola.calendula.domain.isCrossedOut import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme import de.jeanlucmakiola.floret.identity.animateItemMotion import de.jeanlucmakiola.floret.identity.fadeThrough @@ -94,6 +94,7 @@ import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog import de.jeanlucmakiola.calendula.ui.common.eventAccent +import de.jeanlucmakiola.calendula.domain.coversWholeDays import de.jeanlucmakiola.calendula.domain.spanFirstDay import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter @@ -499,7 +500,7 @@ private fun SearchResultRow( // Faded like a past event anywhere else in the app — search reaches back // through the whole history. modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier, - title = declinedTitle(marked(title, hit.titleSpans, highlight), event.isDeclined), + title = declinedTitle(marked(title, hit.titleSpans, highlight), event.isCrossedOut), summary = searchSummary(hit, highlight), position = position, minHeight = 64.dp, @@ -609,7 +610,7 @@ private fun searchSummary(hit: SearchHit, highlight: SpanStyle): AnnotatedString .format(event.spanFirstDay(TimeZone.currentSystemDefault()).toJavaLocalDate()) } val use24Hour = LocalUse24HourFormat.current - val timeText = if (event.isAllDay) { + val timeText = if (event.coversWholeDays(TimeZone.currentSystemDefault())) { stringResource(R.string.event_detail_all_day) } else { remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt index c780a73..cac3d08 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt @@ -33,6 +33,7 @@ import kotlinx.datetime.DayOfWeek internal fun MonthViewStylePicker( selected: MonthViewStyle, weekStart: DayOfWeek, + showStartTimes: Boolean, onSelect: (MonthViewStyle) -> Unit, onDismiss: () -> Unit, ) { @@ -50,6 +51,7 @@ internal fun MonthViewStylePicker( MonthStylePreview( style = shown, weekStart = weekStart, + showStartTimes = showStartTimes, height = PREVIEW_HEIGHT, ) } 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..3bab744 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 @@ -63,6 +63,7 @@ import de.jeanlucmakiola.floret.components.GroupedListInset import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedSectionHeader import de.jeanlucmakiola.floret.components.LanguagePickerRow +import de.jeanlucmakiola.calendula.widget.requestWidgetRedraw import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.crash.CrashReportDialog import de.jeanlucmakiola.floret.crash.CrashReporter @@ -155,7 +156,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, @@ -258,15 +259,16 @@ private fun SettingsHub( leading = { CategoryIcon(Icons.Default.Widgets, ChipAccent.Tertiary) }, onClick = { onOpenSection(SettingsSection.Widgets) }, ) + val context = LocalContext.current LanguagePickerRow( position = Position.Middle, title = stringResource(R.string.settings_language), autoLabel = stringResource(R.string.settings_language_auto), localesConfig = R.xml.locales_config, predictiveBack = true, + onChange = { requestWidgetRedraw(context) }, // Invite contributions right where users pick their language. header = { - val context = LocalContext.current val translateUrl = stringResource(R.string.about_translate_url) GroupedRow( title = stringResource(R.string.settings_translate), 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..d97e7a0 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 @@ -43,6 +42,8 @@ data class SettingsUiState( val dimCompletedEvents: Boolean = false, /** Whether the Month grid shows calendar-week numbers in a left gutter (#25). */ val showWeekNumbers: Boolean = false, + /** Whether Month chips may show start times before their titles (#476). */ + val monthShowStartTimes: Boolean = true, /** Whether the jump-to-today control sits in the top bar instead of the FAB (#60). */ val todayButtonInToolbar: Boolean = false, /** Whether events can be dragged to another slot to reschedule them (#68, #173). */ @@ -51,14 +52,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..812910d 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 @@ -114,14 +101,13 @@ class SettingsViewModel @Inject constructor( pastEventDisplay = this[SettingsKeys.pastEventDisplay], dimCompletedEvents = this[SettingsKeys.dimCompletedEvents], showWeekNumbers = this[SettingsKeys.showWeekNumbers], + monthShowStartTimes = this[SettingsKeys.monthShowStartTimes], todayButtonInToolbar = this[SettingsKeys.todayButtonInToolbar], 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 +167,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 +249,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 +277,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) } @@ -375,6 +293,8 @@ class SettingsViewModel @Inject constructor( fun setShowWeekNumbers(enabled: Boolean) = save { setShowWeekNumbers(enabled) } + fun setMonthShowStartTimes(enabled: Boolean) = save { setMonthShowStartTimes(enabled) } + fun setTodayButtonInToolbar(enabled: Boolean) = save { setTodayButtonInToolbar(enabled) } fun setDragToReschedule(enabled: Boolean) = save { setDragToReschedule(enabled) } @@ -396,19 +316,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/ViewsSettings.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt index b214efc..d6b8eab 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/ViewsSettings.kt @@ -160,6 +160,13 @@ internal fun ViewsScreen( checked = state.showWeekNumbers, onCheckedChange = viewModel::setShowWeekNumbers, summary = stringResource(R.string.settings_week_numbers_summary), + position = Position.Middle, + ) + GroupedSwitchRow( + title = stringResource(R.string.settings_month_start_times), + checked = state.monthShowStartTimes, + onCheckedChange = viewModel::setMonthShowStartTimes, + summary = stringResource(R.string.settings_month_start_times_summary), position = Position.Bottom, ) @@ -296,6 +303,7 @@ internal fun ViewsScreen( selected = state.monthViewStyle, // The preview is a real grid, so it uses the real week start too. weekStart = state.weekStart.resolveFirstDay(currentLocale()), + showStartTimes = state.monthShowStartTimes, onSelect = viewModel::setMonthViewStyle, onDismiss = { showMonthStyle = false }, ) @@ -324,6 +332,7 @@ internal fun ViewsScreen( selected = state.weekStart, // The preview is a real grid, so it uses the user's own month style. monthStyle = state.monthViewStyle, + showStartTimes = state.monthShowStartTimes, options = WEEK_START_OPTIONS, onSelect = viewModel::setWeekStart, onDismiss = { showWeekStart = false }, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WeekStartPicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WeekStartPicker.kt index a7682fb..0f1888c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WeekStartPicker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/WeekStartPicker.kt @@ -27,6 +27,7 @@ import de.jeanlucmakiola.floret.locale.currentLocale internal fun WeekStartPicker( selected: WeekStartPref, monthStyle: MonthViewStyle, + showStartTimes: Boolean, options: List, onSelect: (WeekStartPref) -> Unit, onDismiss: () -> Unit, @@ -46,6 +47,7 @@ internal fun WeekStartPicker( MonthStylePreview( style = monthStyle, weekStart = day, + showStartTimes = showStartTimes, height = PREVIEW_HEIGHT, ) } 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/ui/week/TimelineActions.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/TimelineActions.kt index 2a0e69c..6a338bf 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/TimelineActions.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/TimelineActions.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.common.StripDrop import de.jeanlucmakiola.calendula.ui.common.TimelineDragController import de.jeanlucmakiola.calendula.ui.common.TimelineDrop import de.jeanlucmakiola.calendula.ui.common.TimelineResizeDrop @@ -25,6 +26,7 @@ internal class TimelineActions( val onCreateAt: (LocalDate, Int) -> Unit, val onDrop: (TimelineDrop) -> Unit, val onResize: (TimelineResizeDrop) -> Unit, + val onStripDrop: (StripDrop) -> Unit, ) /** The callbacks are read through their latest values, so the holder outlives them. */ @@ -37,12 +39,14 @@ internal fun rememberTimelineActions( onCreateAt: (LocalDate, Int) -> Unit, onDrop: (TimelineDrop) -> Unit, onResize: (TimelineResizeDrop) -> Unit, + onStripDrop: (StripDrop) -> Unit, ): TimelineActions { val eventClick = rememberUpdatedState(onEventClick) val openDay = rememberUpdatedState(onOpenDay) val createAt = rememberUpdatedState(onCreateAt) val drop = rememberUpdatedState(onDrop) val resize = rememberUpdatedState(onResize) + val stripDrop = rememberUpdatedState(onStripDrop) return remember(scrollState, dragController) { TimelineActions( scrollState = scrollState, @@ -52,6 +56,7 @@ internal fun rememberTimelineActions( onCreateAt = { date, minutes -> createAt.value(date, minutes) }, onDrop = { drop.value(it) }, onResize = { resize.value(it) }, + onStripDrop = { stripDrop.value(it) }, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt index 150b3f4..5e87b44 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekPaging.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.ui.week +import de.jeanlucmakiola.calendula.ui.common.CalendarSpan import de.jeanlucmakiola.calendula.ui.common.startOfWeek import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek @@ -7,16 +8,7 @@ import kotlinx.datetime.LocalDate import kotlinx.datetime.daysUntil import kotlinx.datetime.plus -/** Pages the week pager spans — a little under four centuries. */ -internal const val WEEK_PAGE_COUNT: Int = 20_000 - -/** Pages the multi-day pager spans — over three centuries at the widest page. */ -internal const val DAYS_PAGE_COUNT: Int = 40_000 - -/** Pages the day pager spans — a little under four centuries. */ -internal const val DAY_PAGE_COUNT: Int = 140_000 - -/** A fixed day every grid's pages are counted from, at its middle page. */ +/** A fixed day every grid's pages are counted from. */ private val DAYS_EPOCH = LocalDate(2000, 1, 1) /** The week the week pager's middle page shows; any date works, it only has to stay fixed. */ @@ -24,13 +16,19 @@ private val WEEK_EPOCH = LocalDate(2000, 1, 3) /** * How a timeline pager cuts the calendar: runs of [dayCount] days, offset - * [phase] days from a fixed epoch, over [pageCount] pages centred on it. The - * multi-day view's phase (#193) stays put while paging and only changes when a - * jump moves another date to the first column. + * [phase] days from a fixed epoch, over as many pages as cover [CalendarSpan]. + * The multi-day view's phase (#193) stays put while paging and only changes when + * a jump moves another date to the first column. */ -data class DaysGrid(val dayCount: Int, val phase: Int, val pageCount: Int = DAYS_PAGE_COUNT) { +data class DaysGrid(val dayCount: Int, val phase: Int) { - private val epochPage: Int get() = pageCount / 2 + /** The page showing the epoch; page 0 is the one holding [CalendarSpan.FIRST_DAY]. */ + private val epochPage: Int get() = -pagesFromEpoch(CalendarSpan.FIRST_DAY) + + val pageCount: Int get() = epochPage + pagesFromEpoch(CalendarSpan.LAST_DAY) + 1 + + private fun pagesFromEpoch(date: LocalDate): Int = + Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, dayCount) /** First day shown on pager [page]. */ fun startForPage(page: Int): LocalDate = @@ -38,18 +36,16 @@ data class DaysGrid(val dayCount: Int, val phase: Int, val pageCount: Int = DAYS /** The pager page showing [date]. */ fun pageFor(date: LocalDate): Int = - (epochPage + Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, dayCount)) - .coerceIn(0, pageCount - 1) + (epochPage + pagesFromEpoch(date)).coerceIn(0, pageCount - 1) companion object { /** Single days: every date starts a page of its own. */ - val days: DaysGrid = DaysGrid(dayCount = 1, phase = 0, pageCount = DAY_PAGE_COUNT) + val days: DaysGrid = DaysGrid(dayCount = 1, phase = 0) /** Whole weeks, starting on [firstDay]. */ fun weeks(firstDay: DayOfWeek): DaysGrid = DaysGrid( dayCount = 7, phase = DAYS_EPOCH.daysUntil(WEEK_EPOCH.startOfWeek(firstDay)), - pageCount = WEEK_PAGE_COUNT, ) /** The grid of [dayCount]-day pages on which [date] is a page's first day. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 43a668c..72c0999 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -106,9 +106,16 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.runtime.State +import androidx.compose.ui.layout.ParentDataModifier +import androidx.compose.ui.unit.Density import androidx.compose.ui.layout.layout import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity @@ -125,6 +132,9 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.dateZone +import de.jeanlucmakiola.calendula.domain.spanFirstDay +import de.jeanlucmakiola.calendula.domain.spanLastDay import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.ui.common.LocalChipGround import de.jeanlucmakiola.calendula.ui.common.eventPaint @@ -147,10 +157,11 @@ import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.MoveRequest import de.jeanlucmakiola.calendula.ui.common.MoveTarget import de.jeanlucmakiola.calendula.ui.common.TimelineDragController +import de.jeanlucmakiola.calendula.ui.common.StripDrag +import de.jeanlucmakiola.calendula.ui.common.StripDrop import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay import de.jeanlucmakiola.calendula.ui.common.TimelineDrop import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes -import de.jeanlucmakiola.calendula.ui.common.continuesAfter import de.jeanlucmakiola.calendula.ui.common.continuesBefore import de.jeanlucmakiola.calendula.ui.common.ChipCuts import de.jeanlucmakiola.calendula.ui.common.timedBlockCuts @@ -185,8 +196,10 @@ import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom -import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION +import de.jeanlucmakiola.calendula.ui.common.renderedSpan import de.jeanlucmakiola.calendula.ui.common.hourHeight +import de.jeanlucmakiola.calendula.ui.common.dayFitHeight +import de.jeanlucmakiola.calendula.ui.common.FabClearance import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.GUTTER_CONTENT_START_INSET @@ -198,6 +211,7 @@ import de.jeanlucmakiola.calendula.ui.common.hourGridCells import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay import de.jeanlucmakiola.floret.time.isoWeekNumber import kotlinx.coroutines.launch +import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.plus @@ -601,6 +615,21 @@ private fun WeekContent( ) } + // Off the all-day strip: sideways moves days, into the grid makes it timed. + val onStripDrop: (StripDrop) -> Unit = { drop -> + move?.move( + MoveRequest( + eventId = drop.event.eventId, + beginMillis = drop.event.start.toEpochMilliseconds(), + endMillis = drop.event.end.toEpochMilliseconds(), + target = when (drop) { + is StripDrop.Days -> MoveTarget.ByDays(drop.days) + is StripDrop.Timed -> MoveTarget.Span(drop.start, drop.end) + }, + ), + ) + } + // A drafted block (#153) opens the new-event form at its own instants: the // end it is drawn to differs on a DST change (#387). val createDraft: (EventInstance) -> Unit = { draft -> @@ -661,6 +690,7 @@ private fun WeekContent( onCreateAt = onCreateAt, onDrop = onDrop, onResize = onResize, + onStripDrop = onStripDrop, ) Box(modifier = modifier) { @@ -736,7 +766,12 @@ private fun WeekPager( val loaded = pageState as? WeekUiState.Success // Until its days arrive a page shows their frame without events. val empty = remember(pageSpan, today) { emptySpan(pageSpan, today) } - val stripHeight = loaded?.allDayStripHeight() + // A bar held over other days in the strip makes room there before it is dropped. + val held = (dragController.stripDrag ?: dragController.stripSettling) + ?.takeIf { page == pagerState.currentPage } + val zone = LocalTimelineZone.current + val shown = remember(loaded, held, zone) { loaded?.withStripPreview(held, zone) } + val stripHeight = shown?.allDayStripHeight() DisposableEffect(page, stripHeight) { if (stripHeight != null) stripHeights[page] = stripHeight onDispose { stripHeights.remove(page) } @@ -744,8 +779,8 @@ private fun WeekPager( ViewMorphPage(active = morphPageActive(page, pagerState.settledPage)) { WeekPage( // A cached page may predate midnight; today comes off the clock (#373). - state = remember(loaded, empty, today) { - loaded?.takeIf { it.today != today }?.copy(today = today) ?: loaded ?: empty + state = remember(shown, empty, today) { + shown?.takeIf { it.today != today }?.copy(today = today) ?: shown ?: empty }, headerBias = kind.headerBias, allDayHeight = allDayHeight, @@ -757,6 +792,25 @@ private fun WeekPager( } } +/** + * [this] with the strip bar [held] sideways laid out at the days it is over, so + * the bars there move aside live. The bar keeps its own event, and with it its + * key and the gesture on it; one moved off the page keeps a lane it is not seen in. + */ +internal fun WeekUiState.Success.withStripPreview(held: StripDrag?, zone: TimeZone): WeekUiState.Success { + if (held == null || held.overGrid || held.dayShift == 0) return this + val id = held.event.instanceId + val original = allDaySpans.firstOrNull { it.event.instanceId == id } ?: return this + val events = allDaySpans.map { if (it.event.instanceId == id) it.event.shiftedDays(held.dayShift, zone) else it.event } + val spans = layoutAllDay(events, days, zone).map { if (it.event.instanceId == id) it.copy(event = original.event) else it } + return copy(allDaySpans = if (spans.any { it.event.instanceId == id }) spans else spans + original.copy(lane = 0)) +} + +private fun EventInstance.shiftedDays(days: Int, zone: TimeZone): EventInstance { + val dates = dateZone(zone) + return copy(start = start.plus(days, DateTimeUnit.DAY, dates), end = end.plus(days, DateTimeUnit.DAY, dates)) +} + /** A page with no events yet, for one whose days are still loading. */ private fun emptySpan(span: DaySpan, today: LocalDate): WeekUiState.Success { val days = (0 until span.count).map { span.start.plus(it, kotlinx.datetime.DateTimeUnit.DAY) } @@ -892,8 +946,9 @@ private fun WeekFrame( ), ) { val zoom = LocalTimelineZoom.current - val fitHeight = if (animatedAllDayHeight == null) maxHeight + allDayHeight - else maxHeight + val fitHeight = dayFitHeight( + if (animatedAllDayHeight == null) maxHeight + allDayHeight else maxHeight, + ) val hourHeight = zoom.scale.hourHeight(fitHeight) HourGutter( scrollState = actions.scrollState, @@ -992,7 +1047,7 @@ private fun TimelinePageContent( LocalChipGround provides MaterialTheme.colorScheme.surface, ) { AllDayStrip(geometry = geometry, bars = bars, height = allDayHeight, - animatedHeight = animatedAllDayHeight, onEventClick = actions.onEventClick) + animatedHeight = animatedAllDayHeight, actions = actions, active = active) } } Spacer(Modifier.height(TIMELINE_TOP_GAP)) @@ -1127,9 +1182,29 @@ private fun AllDayStrip( bars: List, height: Dp, animatedHeight: (() -> Dp)? = null, - onEventClick: (EventInstance) -> Unit, + actions: TimelineActions, + active: Boolean, ) { val dark = LocalFloretDarkTheme.current + val zone = LocalTimelineZone.current + val dragController = actions.dragController + // Only the page the finger is on draws the drag; the controller is shared. + val held = (dragController.stripDrag ?: dragController.stripSettling)?.takeIf { active } + val sideways = held?.takeIf { !it.overGrid } + val lifted = dragController.liftedInstanceId?.takeIf { active && dragController.stripDrag?.overGrid != false } + val settling = dragController.stripSettling?.takeIf { active } + // Seated once the re-read draws the event at the days it was dropped on. + LaunchedEffect(settling, bars) { + if (settling == null) return@LaunchedEffect + val target = settling.event.spanFirstDay(zone).plus(settling.dayShift, DateTimeUnit.DAY) + val landed = bars.any { bar -> + bar.event.instanceId != settling.event.instanceId && + (bar.event.eventId == settling.event.eventId || + (settling.event.title.isNotBlank() && bar.event.title == settling.event.title)) && + bar.event.spanFirstDay(zone) == target + } + if (landed) dragController.seatStrip() + } Row( modifier = Modifier @@ -1150,12 +1225,20 @@ private fun AllDayStrip( content = { bars.forEach { bar -> key(bar.event.eventId, bar.event.start) { + val moving = sideways?.event?.instanceId == bar.event.instanceId + val lane = animatedStripLane(bar.toLane) AllDayBar( event = bar.event, lastDay = bar.toRange.endInclusive, dark = dark, - onClick = { onEventClick(bar.event) }, + dragController = dragController, + lifted = moving, + ghosted = lifted == bar.event.instanceId, + onClick = { actions.onEventClick(bar.event) }, + onDrop = actions.onStripDrop, modifier = Modifier + .then(if (moving) Modifier.zIndex(1f) else Modifier) + .then(StripLane(lane)) .morphEvent(ViewMorphKey.event(bar.event, bar.keyDate)) .padding(horizontal = 1.dp, vertical = 1.dp), ) @@ -1172,10 +1255,19 @@ private fun AllDayStrip( val progress = geometry.progress() val placed = measurables.mapIndexed { i, measurable -> val bar = bars[i] - val extent = geometry.extent(bar.fromRange, bar.toRange, width, 0f) + val shift = sideways?.takeIf { it.event.instanceId == bar.event.instanceId }?.dayShift ?: 0 + val extent = if (shift == 0) { + geometry.extent(bar.fromRange, bar.toRange, width, 0f) + } else { + // The whole event, not the part this page shows: it may come in from off screen. + val moved = bar.event.spanFirstDay(zone)..bar.event.spanLastDay(zone) + geometry.extent(moved.shiftedBy(shift), moved.shiftedBy(shift), width, 0f) + } val left = extent.left.roundToInt() val barWidth = (extent.right.roundToInt() - left).coerceAtLeast(0) - val y = (lerp(bar.fromLane.toFloat(), bar.toLane.toFloat(), progress) * rowPx).roundToInt() + // Standing still a bar tweens to a new lane, as bars make room for a dragged one. + val lane = (measurable.parentData as? StripLane)?.lane?.takeIf { bar.fromLane == bar.toLane } + val y = ((lane?.value ?: lerp(bar.fromLane.toFloat(), bar.toLane.toFloat(), progress)) * rowPx).roundToInt() Triple(measurable.measure(Constraints.fixed(barWidth, rowPx.roundToInt())), left, y) } layout(constraints.maxWidth, constraints.maxHeight) { @@ -1185,12 +1277,31 @@ private fun AllDayStrip( } } +private data class StripLane(val lane: State) : ParentDataModifier { + override fun Density.modifyParentData(parentData: Any?): Any = this@StripLane +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun animatedStripLane(lane: Int): State { + val spec: FiniteAnimationSpec = + if (rememberReduceMotion()) snap() else MaterialTheme.motionScheme.fastSpatialSpec() + return animateFloatAsState(lane.toFloat(), spec, label = "strip-lane") +} + +private fun ClosedRange.shiftedBy(days: Int): ClosedRange = + start.plus(days, DateTimeUnit.DAY)..endInclusive.plus(days, DateTimeUnit.DAY) + @Composable private fun AllDayBar( event: EventInstance, lastDay: LocalDate, dark: Boolean, + dragController: TimelineDragController, + lifted: Boolean, + ghosted: Boolean, onClick: () -> Unit, + onDrop: (StripDrop) -> Unit, modifier: Modifier = Modifier, ) { val title = event.title.ifBlank { stringResource(R.string.event_untitled) } @@ -1198,8 +1309,29 @@ private fun AllDayBar( // A bar is one piece, so it dims once the last day it covers here has passed. val dimmed = dimCutoff != null && event.isPastOn(lastDay, dimCutoff) val paint = eventPaint(event, dark) + val zone = LocalTimelineZone.current + val draggable = eventDragAllowed(event) + // Long press lifts the bar: sideways moves its days, down into the grid makes it timed. + val dragModifier = rememberEventDragSource( + enabled = draggable, + key = event.instanceId, + onPickUp = { pointer, _, _ -> + dragController.beginStrip(event, zone, pointer) + if (dragController.stripDrag != null) PickUp.Drag else PickUp.None + }, + onMove = dragController::move, + onDrop = { dragController.finishStrip()?.let(onDrop) }, + onCancel = dragController::cancel, + onRelease = dragController::cancel, + ) + // Ghosted while it hangs over the grid as a timed block, and until that drop lands. + val ghost = ghostAlpha(ghosted) + val raise = selectionLift(lifted) Box( modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) + .alpha(ghost) + .selectionLift(raise, EventChipShape) + .then(dragModifier) .eventSurface(paint, EventChipShape) .clickable(onClick = onClick) .padding(horizontal = 6.dp, vertical = 2.dp) @@ -1254,7 +1386,9 @@ private fun Timeline( else Modifier.stableMorphViewport(animatedAllDayHeight, fullViewportHeight), ), ) { - val fitHeight = if (animatedAllDayHeight == null) maxHeight + allDayHeight else maxHeight + val fitHeight = dayFitHeight( + if (animatedAllDayHeight == null) maxHeight + allDayHeight else maxHeight, + ) val hourHeight = zoom.scale.hourHeight(fitHeight) val totalHeight = hourHeight * 24 // The pinch sits above the scroll viewport: it has to outrank the @@ -1287,9 +1421,11 @@ private fun Timeline( .fillMaxSize() .then(pinch) .padding(end = TIMELINE_CONTENT_END_INSET) - .clip(RoundedCornerShape(16.dp)) + .clip(RoundedCornerShape(TIMELINE_CORNER)) .verticalScroll(scrollState) - .onGloballyPositioned { if (active) dragController.geometry.viewport = it }, + .onGloballyPositioned { if (active) dragController.geometry.viewport = it } + // Past midnight, outside the columns: nothing there takes a tap or a drop. + .padding(bottom = FabClearance), ) { TimelineColumns( geometry = geometry, @@ -1325,6 +1461,7 @@ private fun Timeline( visibleTop = visibleTop, viewportHeight = visibleViewport, actions = actions, + shape = columnShape(day == geometry.dates.first(), day == geometry.dates.last()), modifier = Modifier.fillMaxHeight(), ) } @@ -1332,6 +1469,19 @@ private fun Timeline( } } +/** + * The outer columns round their bottom corner at midnight: the room left below + * for the buttons means the rounded window no longer does it there. + */ +private fun columnShape(first: Boolean, last: Boolean): Shape = + if (!first && !last) RectangleShape + else RoundedCornerShape( + bottomStart = if (first) TIMELINE_CORNER else 0.dp, + bottomEnd = if (last) TIMELINE_CORNER else 0.dp, + ) + +private val TIMELINE_CORNER = 16.dp + @Composable private fun DayColumnCard( blocks: List, @@ -1343,6 +1493,7 @@ private fun DayColumnCard( visibleTop: () -> Float, viewportHeight: () -> Float, actions: TimelineActions, + shape: Shape = RectangleShape, modifier: Modifier = Modifier, ) { val scrollState = actions.scrollState @@ -1373,8 +1524,8 @@ private fun DayColumnCard( val draftSurface = rememberDraftSurface(date, edges, hourPx, dragController, actions.onDrop) Card( // Plain rectangular columns — the soft corners come from the outer - // rounded scroll viewport, so inner rounding would look odd at the edges. - shape = RectangleShape, + // rounded scroll viewport, apart from the outer ones' bottoms ([columnShape]). + shape = shape, colors = CardDefaults.cardColors( // With the grid on, the container colour moves onto the hour cells // and the column behind them recedes to surface, so the gap between @@ -1414,7 +1565,6 @@ private fun DayColumnCard( // block or handle on top can claim the press first. .then(draftSurface), ) { - val minEventHeight = hourHeight * MIN_EVENT_FRACTION // Keyed by event, so a block that changes time or lane is the *same* // composable afterwards and tweens there. The ordinal disambiguates // a column holding two occurrences of one series. @@ -1422,9 +1572,17 @@ private fun DayColumnCard( shown.forEach { block -> val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1 key(block.event.eventId, ordinal) { - val top = hourHeight * (block.startMin / 60f) - val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f) - val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight + // Where this block is cut at midnight, which decides both + // its shape and the half-gap it gives up to the hour cells. + val cuts = remember(block, date, zone) { + timedBlockCuts( + block.continuesBefore(date, zone), + block.continuesAfter, + ) + } + val span = remember(block) { block.renderedSpan() } + val top = hourHeight * (span.topMin / 60f) + val height = hourHeight * ((span.bottomMin - span.topMin) / 60f) val place = animatedBlockPlacement( xFraction = block.lane.toFloat() / block.laneCount, widthFraction = 1f / block.laneCount, @@ -1432,14 +1590,6 @@ private fun DayColumnCard( height = height, landing = dragController.ghosts(block), ) - // Where this block is cut at midnight, which decides both - // its shape and the half-gap it gives up to the hour cells. - val cuts = remember(block, date, zone) { - timedBlockCuts( - block.continuesBefore(date, zone), - block.continuesAfter(date, zone), - ) - } val topInset = hourCellBlockInset(showHourGrid, cuts.top) val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom) val blockHeight = (place.height - topInset - bottomInset) @@ -1775,7 +1925,7 @@ private fun rememberDraftSurface( block = block, zone = zone, pointerInRoot = pointer, - blockInRoot = node + Offset(0f, startMin / 60f * hour), + blockInRoot = node + Offset(0f, block.renderedSpan().topMin / 60f * hour), ) true }, @@ -1823,8 +1973,10 @@ private fun WeekLoading(dayCount: Int) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) { // Same scale resolution as the loaded timeline, so the skeleton's // columns don't resize the moment the real week arrives. - val fitHeight = maxHeight + SKELETON_HEADER_HEIGHT + SKELETON_HEADER_PADDING * 2 - - headerHeight - TIMELINE_TOP_GAP + val fitHeight = dayFitHeight( + maxHeight + SKELETON_HEADER_HEIGHT + SKELETON_HEADER_PADDING * 2 - + headerHeight - TIMELINE_TOP_GAP, + ) val hourHeight = scale.hourHeight(fitHeight) val totalHeight = hourHeight * 24 // The skeleton wears the loaded columns' own ground, so the arrival @@ -1837,7 +1989,8 @@ private fun WeekLoading(dayCount: Int) { modifier = Modifier .fillMaxSize() .padding(end = TIMELINE_CONTENT_END_INSET) - .verticalScroll(scrollState), + .verticalScroll(scrollState) + .padding(bottom = FabClearance), ) { Spacer(Modifier.width(GUTTER_WIDTH)) repeat(dayCount) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekUiState.kt index d4a5993..331c8c6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekUiState.kt @@ -15,6 +15,8 @@ import kotlinx.datetime.LocalDate * minimum tap-target height) * @param lane 0-based column within [laneCount] * @param laneCount number of columns the event's overlap-cluster needs + * @param continuesAfter whether the event runs past this day's midnight, so the + * block is cut there */ data class TimedBlock( val event: EventInstance, @@ -22,6 +24,7 @@ data class TimedBlock( val endMin: Int, val lane: Int, val laneCount: Int, + val continuesAfter: Boolean = false, ) /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt index fe5992f..9a34d71 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt @@ -1,6 +1,7 @@ package de.jeanlucmakiola.calendula.ui.week import de.jeanlucmakiola.calendula.ui.common.dayRange +import de.jeanlucmakiola.calendula.ui.common.renderedSpan import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -13,7 +14,9 @@ import de.jeanlucmakiola.calendula.data.time.currentDate import de.jeanlucmakiola.calendula.data.time.stateFollowingDays import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.coversWholeDays import de.jeanlucmakiola.calendula.domain.displayPriority +import de.jeanlucmakiola.calendula.domain.displayStart import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.calendarListFailure import de.jeanlucmakiola.calendula.ui.common.PageStateCache @@ -206,8 +209,7 @@ abstract class PagedTimelineViewModel( ): WeekUiState { calendarListFailure(calendars)?.let { return WeekUiState.Failure(it) } val days = (0 until span.count).map { span.start.plus(it, DateTimeUnit.DAY) } - val allDay = instances.filter { it.isAllDay } - val timed = instances.filterNot { it.isAllDay } + val (allDay, timed) = instances.splitWholeDays(zone) return WeekUiState.Success( weekStart = span.start, today = today, @@ -292,20 +294,21 @@ internal fun layoutAllDay( days: List, zone: TimeZone, ): List { - data class Raw(val event: EventInstance, val startCol: Int, val endCol: Int) + data class Raw(val event: EventInstance, val startCol: Int, val endCol: Int, val priority: Int) val raw = events .mapNotNull { ev -> val covered = days.indices.filter { ev.coversDay(days[it], zone) } - if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last()) + if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last(), ev.displayPriority(zone)) } // Declined bars are packed after every other one, so on any day they // cover they land in a lane below it (#230). .sortedWith( - compareBy { it.event.displayPriority(zone) } + compareBy { it.priority } .thenBy { it.startCol } .thenBy { it.endCol } - .thenBy { it.event.start } + .thenBy { it.event.displayStart(zone) } + .thenBy { it.event.title } .thenBy { it.event.instanceId }, ) @@ -426,12 +429,21 @@ internal fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean { return coversDay(secondDay, zone) } +/** + * Split events into those for the all-day strip and those for the time grid: + * all-day events and timed ones from midnight to midnight go to the strip (#296). + */ +internal fun List.splitWholeDays(zone: TimeZone): Pair, List> = + partition { it.coversWholeDays(zone) } + /** * Clip [events] to a single [day] and assign lanes so overlapping events render * side-by-side. Lane count is computed per overlap-cluster (a maximal run of * chained-overlapping events), matching the common phone week-view behaviour. + * Overlap is judged on each block's [renderedSpan], so a short block's floor + * (lifted off midnight, #483) never hides another block. * - * All-day events are ignored here — they live in the all-day strip. + * Takes the timed half of [splitWholeDays]; the rest lives in the all-day strip. */ internal fun layoutDay( events: List, @@ -441,55 +453,53 @@ internal fun layoutDay( val dayStart = day.atStartOfDayIn(zone) val dayEnd = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone) - data class Raw(val event: EventInstance, val startMin: Int, val endMin: Int) - val raw = events.asSequence() - .filterNot { it.isAllDay } .mapNotNull { ev -> if (ev.start == ev.end) { // Instant event: keep only if the point falls inside this day. if (ev.start < dayStart || ev.start >= dayEnd) return@mapNotNull null val m = wallClockMinutes(ev.start, day, zone) - Raw(ev, m, m) + TimedBlock(ev, m, m, lane = 0, laneCount = 1) } else { val s = maxOf(ev.start, dayStart) val e = minOf(ev.end, dayEnd) if (e <= s) return@mapNotNull null val startMin = wallClockMinutes(s, day, zone) val endMin = wallClockEndMinutes(startMin, s, e, day, zone).coerceAtMost(MINUTES_PER_DAY) - Raw(ev, startMin, endMin) + TimedBlock(ev, startMin, endMin, lane = 0, laneCount = 1, continuesAfter = ev.end > dayEnd) } } - .sortedWith(compareBy({ it.startMin }, { it.endMin })) + .map { it to it.renderedSpan() } + .sortedWith(compareBy({ it.second.topMin }, { it.second.bottomMin })) .toList() val result = ArrayList(raw.size) var i = 0 while (i < raw.size) { // Grow a cluster of chained-overlapping events. - var clusterEnd = raw[i].endMin + var clusterEnd = raw[i].second.bottomMin var j = i + 1 - while (j < raw.size && raw[j].startMin < clusterEnd) { - clusterEnd = maxOf(clusterEnd, raw[j].endMin) + while (j < raw.size && raw[j].second.topMin < clusterEnd) { + clusterEnd = maxOf(clusterEnd, raw[j].second.bottomMin) j++ } val cluster = raw.subList(i, j) // Greedy first-fit column assignment (= max overlap depth in the cluster). - val laneEnd = ArrayList() + val laneEnd = ArrayList() val lanes = IntArray(cluster.size) - cluster.forEachIndexed { k, r -> - var placed = laneEnd.indexOfFirst { it <= r.startMin } + cluster.forEachIndexed { k, (_, span) -> + var placed = laneEnd.indexOfFirst { it <= span.topMin } if (placed == -1) { - laneEnd.add(r.endMin) + laneEnd.add(span.bottomMin) placed = laneEnd.size - 1 } else { - laneEnd[placed] = r.endMin + laneEnd[placed] = span.bottomMin } lanes[k] = placed } val laneCount = laneEnd.size - cluster.forEachIndexed { k, r -> - result.add(TimedBlock(r.event, r.startMin, r.endMin, lanes[k], laneCount)) + cluster.forEachIndexed { k, (block, _) -> + result.add(block.copy(lane = lanes[k], laneCount = laneCount)) } i = j } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt index e7d87a0..fb3a362 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt @@ -61,6 +61,7 @@ internal fun WeekViewPreview( onCreateAt = { _, _ -> }, onDrop = {}, onResize = {}, + onStripDrop = {}, ), ) } @@ -75,12 +76,12 @@ private fun sampleWeekState( ): WeekUiState.Success { val start = if (dayCount == 7) today.startOfWeek(weekStart) else today val days = (0 until dayCount).map { start.plus(it, DateTimeUnit.DAY) } - val events = sampleTimelineEvents(days, today, zone) + val (allDay, timed) = sampleTimelineEvents(days, today, zone).splitWholeDays(zone) return WeekUiState.Success( weekStart = start, today = today, days = days, - allDaySpans = layoutAllDay(events.filter { it.isAllDay }, days, zone), - timedByDay = days.associateWith { layoutDay(events, it, zone) }, + allDaySpans = layoutAllDay(allDay, days, zone), + timedByDay = days.associateWith { layoutDay(timed, it, zone) }, ) } 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..db9c78e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/CalendulaWidgetReceiver.kt @@ -0,0 +1,52 @@ +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 onDeleted(context: Context, appWidgetIds: IntArray) { + super.onDeleted(context, appWidgetIds) + evictMonthSources(appWidgetIds) + WidgetKind.entries.firstOrNull { it.receiver == this::class.java }?.let { kind -> + appWidgetIds.forEach { WidgetTicks.cancel(context, kind, it) } + } + } + + 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..86be0e9 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,13 @@ import android.Manifest import android.content.Context import android.content.pm.PackageManager import androidx.core.content.ContextCompat +import androidx.glance.GlanceId +import androidx.glance.appwidget.GlanceAppWidgetManager 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 +19,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 +36,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 +46,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,131 +116,154 @@ 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(widgetLocale()) + 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(), ) } internal fun monthIndexOf(date: LocalDate): Int = date.year * 12 + date.month.ordinal -private fun firstDayOfMonthIndex(index: Int): LocalDate = LocalDate(index / 12, index % 12 + 1, 1) +internal fun firstDayOfMonthIndex(index: Int): LocalDate = + LocalDate(Math.floorDiv(index, 12), Math.floorMod(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) + + // Keyed by app-widget id, which a removed widget can still be named by. + private val entries = ConcurrentHashMap() + + fun get(id: Int, center: Int, stamp: Long): Themed? = + entries[id]?.takeIf { it.center == center && it.stamp == stamp && it.source.content.today == today(systemZone()) } + ?.source + + fun put(id: Int, center: Int, stamp: Long, source: Themed) { + val ready = source.content as? MonthWidgetSource.Ready ?: return + entries[id] = Entry(center, stamp, Themed(ready, source.appTheme)) + } + + fun remove(id: Int) { + entries.remove(id) + } +} + +/** Drops the cached windows of removed widgets [appWidgetIds]. */ +internal fun evictMonthSources(appWidgetIds: IntArray) = appWidgetIds.forEach(MonthSourceCache::remove) + +/** + * [loadThemedMonthSource] for widget [id], served from [MonthSourceCache] while its [stamp] holds. + * A [load] already read for this pass saves reading the settings and widget state again. + */ +internal suspend fun Context.loadCachedMonthSource( + id: GlanceId, + centerIndex: Int, + stamp: Long, + load: WidgetLoadContext? = null, +): Themed { + val key = GlanceAppWidgetManager(this).getAppWidgetId(id) + return MonthSourceCache.get(key, centerIndex, stamp) + ?: loadThemedMonthSource(centerIndex, load?.hiddenCalendars ?: widgetState(id).hiddenCalendars, load?.settings) + .also { MonthSourceCache.put(key, 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, + settings: WidgetSettings? = null, +): Themed { + val resolved = settings ?: widgetEntryPoint().settingsPrefs().widgetSettings.first() + return Themed(queryMonthSource(centerIndex, resolved, hiddenCalendars), resolved.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 +272,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(widgetLocale()), + 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(widgetLocale()) + 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..311715f --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetEventRow.kt @@ -0,0 +1,121 @@ +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.isCrossedOut +import de.jeanlucmakiola.calendula.ui.common.CalendarView +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 + +/** A past event's fill when dimmed: fainter than the app's whole-row dim, so its text can stay readable (#90). */ +internal const val WidgetDimFillAlpha = 0.3f + +/** A dimmed event's ink, held above its fill. */ +private const val WidgetDimInkAlpha = 0.65f + +/** Ink for an event on [fill], picked against the opaque tone and faded when the fill is dimmed. */ +internal fun widgetEventInk(fill: Color, alpha: Float): Color = + eventInk(fill.copy(alpha = 1f), if (fill.alpha < 1f) alpha * WidgetDimInkAlpha else alpha) + +/** 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 = WidgetDimFillAlpha) else it } + val day = tone(dark = false) + val night = tone(dark = true) + 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 = widgetEventInk(day, 1f), night = widgetEventInk(night, 1f)) + } + val timeInk = if (quiet) { + GlanceTheme.colors.onSurfaceVariant + } else { + themedColor(day = widgetEventInk(day, SECONDARY_INK_ALPHA), night = widgetEventInk(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.isCrossedOut), + ), + ) + 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..1fb5ac2 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetKind.kt @@ -0,0 +1,123 @@ +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), + ), + ; + + /** Whether a calendar change can change what it draws; Date and Quick add draw no events. */ + val drawsEvents: Boolean get() = this != QUICK_ADD && this != DATE + + 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..9022f75 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetParts.kt @@ -0,0 +1,59 @@ +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 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, widgetLocale()) +} + +/** "Today" or "Tomorrow" for those two days, else null. */ +internal fun relativeDayWord(context: Context, date: LocalDate, today: LocalDate): String? = when (date) { + today -> context.getString(R.string.agenda_header_today) + today.plus(1, DateTimeUnit.DAY) -> context.getString(R.string.agenda_header_tomorrow) + else -> null +} + +/** "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 = widgetLocale() + return relativeDayWord(context, date, today) ?: when { + 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..db6603c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetPickerPreview.kt @@ -0,0 +1,191 @@ +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 kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +/** + * Republishes the generated picker previews (Android 15+) when anything they + * draw with has changed: the app version, language, today's date, or the + * theme, week start, time format and colour softening. + */ +internal suspend fun publishCalendulaWidgetPreviews(context: Context) { + val settings = context.widgetEntryPoint().settingsPrefs().widgetSettings.first() + val updated = context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime + val is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context)) + publishWidgetPreviews( + context, + version = listOf( + updated, + widgetLocale().toLanguageTag(), + today(systemZone()), + settings.themeMode, + settings.weekStart, + is24Hour, + settings.soften, + ).joinToString("-"), + 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(widgetLocale()) + 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..c17fcae --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedraw.kt @@ -0,0 +1,188 @@ +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 android.util.Log +import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository +import de.jeanlucmakiola.calendula.data.di.ApplicationScope +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.calendula.widget.countdown.repointCountdowns +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.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +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" + +/** Redraws every widget, for app changes they draw with that no other signal reaches, like its language (#90). */ +internal const val ACTION_WIDGET_REDRAW = "de.jeanlucmakiola.calendula.widget.REDRAW" + +/** + * Asks [WidgetRedrawReceiver] to redraw every widget. A broadcast rather than a + * coroutine, so it outlives the activity a language change recreates. + */ +fun requestWidgetRedraw(context: Context) { + context.sendBroadcast(Intent(context, WidgetRedrawReceiver::class.java).setAction(ACTION_WIDGET_REDRAW)) +} + +/** The widgets a calendar change can affect. */ +internal val EVENT_WIDGET_KINDS: List = WidgetKind.entries.filter { it.drawsEvents } + +/** 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, kinds: List = WidgetKind.entries) { + // One kind failing, say a widget removed mid-pass, mustn't stop the others + // or escape the broadcast's coroutine. + kinds.forEach { kind -> + try { + redrawWidgets(context, kind) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Redrawing $kind widgets failed", e) + } + } +} + +private const val TAG = "WidgetRedraw" + +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 -> + try { + val glanceId = glanceManager.getGlanceIdBy(appWidgetId) + if (!appContext.widgetState(glanceId).hasAgendaOptions) kind.widget().updateWithRefresh(appContext, glanceId) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "Redrawing widget $appWidgetId failed", e) + } + } + } +} + +/** 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 + +/** Set while a `PROVIDER_CHANGED` redraw waits out the echo window, so a burst of them redraws once. */ +private val echoRedrawQueued = AtomicBoolean(false) + +private fun providerEchoRemainingMillis(): Long { + val last = lastLocalChange.get() + if (last == 0L) return 0L + return PROVIDER_ECHO_WINDOW_MILLIS - (SystemClock.elapsedRealtime() - last) +} + +/** + * Whether a `PROVIDER_CHANGED` should redraw. Within the echo window of one of + * the app's own changes it may only echo that change, or be a sync landing + * right after it, so it isn't dropped: the first one redraws once the window + * has passed ([awaitProviderEcho]) and the rest of the burst ride along. + */ +internal fun claimProviderRedraw(): Boolean = + providerEchoRemainingMillis() <= 0L || echoRedrawQueued.compareAndSet(false, true) + +/** Waits out the echo window, if one is open, before a `PROVIDER_CHANGED` redraw. */ +internal suspend fun awaitProviderEcho() { + try { + val wait = providerEchoRemainingMillis() + if (wait > 0L) delay(wait) + } finally { + echoRedrawQueued.set(false) + } +} + +/** + * 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 refreshers = WidgetKind.entries.associateWith { kind -> WidgetRefresher(scope) { redrawWidgets(context, kind) } } + val all = refreshers.values + val unconfigured = WidgetRefresher(scope) { redrawUnconfiguredWidgets(context) } + watch(repository.localChanges) { + lastLocalChange.set(SystemClock.elapsedRealtime()) + EVENT_WIDGET_KINDS.forEach { refreshers.getValue(it).request() } + } + watch(repository.seriesSplits) { split -> + scope.launch(Dispatchers.IO) { repointCountdowns(context, split.oldEventId, split.newEventId) } + } + watch(prefs.widgetSettings.map { it.shared }.distinctUntilChanged().drop(1)) { + all.forEach { it.request() } + scope.launch(Dispatchers.IO) { publishCalendulaWidgetPreviews(context) } + } + watch(prefs.widgetSettings.map { it.agendaDefaults }.distinctUntilChanged().drop(1)) { + unconfigured.request() + } + } + + private fun watch(changes: Flow, onChange: (T) -> Unit) { + scope.launch(Dispatchers.IO, start = CoroutineStart.UNDISPATCHED) { + changes.collect { onChange(it) } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedrawReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedrawReceiver.kt new file mode 100644 index 0000000..d097541 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetRedrawReceiver.kt @@ -0,0 +1,22 @@ +package de.jeanlucmakiola.calendula.widget + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import de.jeanlucmakiola.floret.android.goAsync +import de.jeanlucmakiola.floret.locale.AppLanguage + +/** + * Takes [requestWidgetRedraw]'s in-app redraw. Not exported, unlike + * [WidgetUpdateReceiver], so no other app can make every widget reload. + */ +class WidgetRedrawReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != ACTION_WIDGET_REDRAW) return + val appContext = context.applicationContext + goAsync { + AppLanguage.applyDefault(appContext) + redrawWidgets(appContext) + } + } +} 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..6dd319e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTheme.kt @@ -1,44 +1,63 @@ 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.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.glance.LocalContext 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.locale.AppLanguage +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) } /** * Glance's counterpart to the app's `declinedDecoration`: a declined invitation * reads the same on the home screen as it does inside the app (#180). */ -fun glanceDeclinedDecoration(isDeclined: Boolean): TextDecoration = - if (isDeclined) TextDecoration.LineThrough else TextDecoration.None +fun glanceDeclinedDecoration(isCrossedOut: Boolean): TextDecoration = + if (isCrossedOut) TextDecoration.LineThrough else TextDecoration.None + +/** Static, so a language change recomposes the whole widget rather than letting unchanged parts skip. */ +private val LocalWidgetLanguage = staticCompositionLocalOf { null } + +/** + * Draws [content] in the app's own language (#90): [LocalContext] and the + * default locale follow it, which below API 33 nothing else does outside an + * AppCompat activity. Read on every recomposition, so a redraw picks up a change. + */ +@Composable +internal fun InAppLanguage(content: @Composable () -> Unit) { + val base = LocalContext.current + val tag = AppLanguage.currentTag(base) + AppLanguage.applyDefault(base, tag) + val localized = remember(base, tag) { AppLanguage.localized(base, tag) } + CompositionLocalProvider(LocalContext provides localized, LocalWidgetLanguage provides tag, content = content) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTicks.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTicks.kt new file mode 100644 index 0000000..6638d3f --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetTicks.kt @@ -0,0 +1,79 @@ +package de.jeanlucmakiola.calendula.widget + +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.glance.appwidget.GlanceAppWidgetManager +import de.jeanlucmakiola.floret.android.goAsync +import de.jeanlucmakiola.floret.glance.updateWithRefresh +import kotlinx.coroutines.CancellationException +import kotlin.time.Instant + +/** + * One alarm per placed widget, for the moment what it shows next changes: a + * countdown's label, the Today list's last event ending. 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 WidgetTicks { + private const val ACTION_TICK = "de.jeanlucmakiola.calendula.widget.TICK" + internal const val EXTRA_KIND = "de.jeanlucmakiola.calendula.widget.KIND" + + fun schedule(context: Context, kind: WidgetKind, appWidgetId: Int, at: Instant) { + val alarms = context.getSystemService(AlarmManager::class.java) ?: return + val intent = pendingIntent(context, kind, 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, kind: WidgetKind, appWidgetId: Int) { + context.getSystemService(AlarmManager::class.java)?.cancel(pendingIntent(context, kind, appWidgetId)) + } + + private fun pendingIntent(context: Context, kind: WidgetKind, appWidgetId: Int): PendingIntent = + PendingIntent.getBroadcast( + context, + 0, + Intent(context, WidgetTickReceiver::class.java) + .setAction(ACTION_TICK) + // Keys the PendingIntent per widget. + .setData("calendula://tick/${kind.name}/$appWidgetId".toUri()) + .putExtra(EXTRA_KIND, kind.name) + .putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) +} + +/** Reloads the widget whose content just changed; the load arms its next tick. */ +class WidgetTickReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID) + val kind = intent.getStringExtra(WidgetTicks.EXTRA_KIND) + ?.let { name -> WidgetKind.entries.firstOrNull { it.name == name } } + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID || kind == null) return + val appContext = context.applicationContext + goAsync { + try { + val glanceId = GlanceAppWidgetManager(appContext).getGlanceIdBy(appWidgetId) + kind.widget().updateWithRefresh(appContext, glanceId) + } catch (e: CancellationException) { + throw e + } catch (_: IllegalArgumentException) { + // The widget is gone. + WidgetTicks.cancel(appContext, kind, appWidgetId) + } catch (_: Exception) { + // A failed redraw leaves the last frame; the next signal retries. + } + } + } +} 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..6f615dd 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,42 @@ 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 de.jeanlucmakiola.floret.locale.AppLanguage +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 the widgets when their data, day or language goes stale, and keeps + * the midnight rollover armed. `PROVIDER_CHANGED` covers changes made outside + * the app, and only redraws the widgets that draw events; 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 waits out the + * echo window and redraws once ([claimProviderRedraw]). */ -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 + + /** This broadcast's action; a receiver instance handles exactly one. */ + private var action: String? = null + + override fun rolloverPendingIntent(context: Context): PendingIntent = widgetRolloverPendingIntent(context) + + override fun skipRedraw(action: String): Boolean { + this.action = action + return action == Intent.ACTION_PROVIDER_CHANGED && !claimProviderRedraw() } - companion object { - /** The app's own midnight wake-up; see [WidgetRolloverScheduler]. */ - const val ACTION_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER" - - /** Both wipe pending alarms, and the host redraws the widgets itself after them. */ - private val REARM_ONLY_ACTIONS = setOf( - Intent.ACTION_BOOT_COMPLETED, - Intent.ACTION_MY_PACKAGE_REPLACED, - ) - - /** - * 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) { + val dataChange = action == Intent.ACTION_PROVIDER_CHANGED + if (dataChange) awaitProviderEcho() + AppLanguage.applyDefault(context) + redrawWidgets(context, if (dataChange) EVENT_WIDGET_KINDS else WidgetKind.entries) } } 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..03dde16 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,30 @@ package de.jeanlucmakiola.calendula.widget.agenda -import de.jeanlucmakiola.calendula.domain.isDeclined +import de.jeanlucmakiola.calendula.widget.InAppLanguage +import de.jeanlucmakiola.calendula.widget.relativeDayWord +import de.jeanlucmakiola.calendula.widget.widgetLocale +import de.jeanlucmakiola.calendula.widget.WidgetKind +import de.jeanlucmakiola.calendula.widget.previewSizeOf +import de.jeanlucmakiola.calendula.widget.provideSamplePreview +import de.jeanlucmakiola.calendula.domain.isCrossedOut 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 +37,57 @@ 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.widget.WidgetDimFillAlpha +import de.jeanlucmakiola.calendula.widget.widgetEventInk +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 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") +import kotlinx.datetime.toJavaDayOfWeek /** * "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 +100,49 @@ 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) } + InAppLanguage { + 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 +152,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 +185,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 +206,221 @@ 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 = WidgetDimFillAlpha) 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( + val titleInk = themedColor(day = widgetEventInk(fill.day, 1f), night = widgetEventInk(fill.night, 1f)) + val timeInk = themedColor( + day = widgetEventInk(fill.day, SECONDARY_INK_ALPHA), + night = widgetEventInk(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 +434,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.isCrossedOut), + ), + ) + Text( + text = eventTimeSummary(context, event, day, is24Hour), + maxLines = 1, + style = TextStyle(color = timeInk, fontSize = metrics.eventTime), + ) } } @@ -480,20 +462,15 @@ 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) { - today -> context.getString(R.string.agenda_header_today) - today.plus(1, DateTimeUnit.DAY) -> context.getString(R.string.agenda_header_tomorrow) - else -> null - } - val locale = Locale.getDefault() + val relative = relativeDayWord(context, date, today) + val locale = widgetLocale() val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day) // Weekday + date in the locale's own field order (compact: no year), rather // than a hardcoded day-month layout. @@ -501,26 +478,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 = widgetLocale(), + currentYear = today.year, + skeleton = "LLLL", +) + +private fun weekdayShort(date: LocalDate): String = + date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, widgetLocale()) + 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..c11d7b7 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/CountdownOptionsSection.kt @@ -0,0 +1,179 @@ +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.domain.dateZone +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(candidate.event.dateZone(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..5847530 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/config/WidgetConfigViewModel.kt @@ -0,0 +1,357 @@ +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.widgetLocale +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.domain.hasEnded +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.datetime.TimeZone +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 + +/** 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(widgetLocale()), + 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 zone = TimeZone.currentSystemDefault() + // A year, a quarter at a time, keeping only each event's first + // occurrence so busy series don't hold the whole year in memory. + val first = LinkedHashMap() + val repeats = HashSet() + for (quarter in 0 until 4) { + val from = now + (quarter * 91).days + val to = if (quarter == 3) now + 365.days else now + ((quarter + 1) * 91).days + repository.instances(from..to).first() + .filter { !it.hasEnded(now, zone) } + .forEach { occurrence -> + val known = first[occurrence.eventId] + when { + known == null -> first[occurrence.eventId] = occurrence + known.start == occurrence.start -> Unit + else -> { + repeats += occurrence.eventId + if (occurrence.start < known.start) first[occurrence.eventId] = occurrence + } + } + } + } + CountdownCandidates.Ready( + first.values + .map { CountdownCandidate(it, repeats = it.eventId in repeats) } + .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..b35eecb --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/Countdown.kt @@ -0,0 +1,99 @@ +package de.jeanlucmakiola.calendula.widget.countdown + +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.EventStatus +import de.jeanlucmakiola.calendula.domain.coversWholeDays +import de.jeanlucmakiola.calendula.domain.isCrossedOut +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().coerceIn(1, 23)) + 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 and not filling whole days (#296), not + * declined or cancelled, not over; of those, the one whose countdown changes + * soonest — a running event's end, else a start. + */ +fun pickNextEvent(instances: List, now: Instant, zone: TimeZone): EventInstance? = + instances + .filter { !it.coversWholeDays(zone) && !it.isCrossedOut && it.end > now } + .minByOrNull { target(it, now) } + +/** A pinned event's occurrence to count to: the running or next one that is not cancelled (#388). */ +fun pickPinnedOccurrence(occurrences: List, now: Instant, zone: TimeZone): EventInstance? = + occurrences.filter { it.status != EventStatus.Cancelled && 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..db9ac1d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownData.kt @@ -0,0 +1,142 @@ +package de.jeanlucmakiola.calendula.widget.countdown + +import android.content.Context +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 androidx.glance.appwidget.state.updateAppWidgetState +import de.jeanlucmakiola.floret.glance.updateWithRefresh +import kotlinx.coroutines.CancellationException +import de.jeanlucmakiola.calendula.data.prefs.is24Hour +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.widget.Themed +import de.jeanlucmakiola.calendula.widget.WidgetKind +import de.jeanlucmakiola.calendula.widget.WidgetLoadContext +import de.jeanlucmakiola.calendula.widget.WidgetTicks +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.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, + systemZone(), + ) + 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) { + WidgetTicks.cancel(this, WidgetKind.COUNTDOWN, appWidgetId) + } else { + val zone = systemZone() + WidgetTicks.schedule( + this, + WidgetKind.COUNTDOWN, + appWidgetId, + nextLabelChange(event.counted(zone), (loaded.content as CountdownData.Ready).now, zone), + ) + } + return loaded +} + +/** + * Moves countdowns pinned to [oldEventId] on to [newEventId] after the app + * split that series, so they keep counting to the part that continues it. + */ +internal suspend fun repointCountdowns(context: Context, oldEventId: Long, newEventId: Long) { + val manager = GlanceAppWidgetManager(context) + manager.getGlanceIds(CountdownWidget::class.java).forEach { id -> + try { + var moved = false + updateAppWidgetState(context, id) { prefs -> + if (prefs[EventKey] == oldEventId) { + prefs[EventKey] = newEventId + moved = true + } + } + if (moved) CountdownWidget().updateWithRefresh(context, id) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + // Gone mid-pass; nothing left to point anywhere. + } + } +} 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..975155d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownWidget.kt @@ -0,0 +1,224 @@ +package de.jeanlucmakiola.calendula.widget.countdown + +import de.jeanlucmakiola.calendula.widget.InAppLanguage +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.domain.coversWholeDays +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.floret.glance.wallpaperBacking +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 [WidgetTicks]. 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) } + InAppLanguage { + CountdownWidgetContent(loaded.content, currentState().widgetAppearance, loaded.appTheme) + } + } + } +} + +class CountdownWidgetReceiver : CalendulaWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = CountdownWidget() +} + +@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 = GlanceModifier.wallpaperBacking(radius = 16.dp) + 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) + val wholeDays = event.coversWholeDays(systemZone()) + return when { + wholeDays && running -> context.getString( + R.string.widget_countdown_until, + relativeDayLabel(context, date.minus(1, DateTimeUnit.DAY), data.today), + ) + wholeDays -> "$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..aa78587 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/date/DateWidget.kt @@ -0,0 +1,126 @@ +package de.jeanlucmakiola.calendula.widget.date + +import de.jeanlucmakiola.calendula.widget.InAppLanguage +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.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.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() } + InAppLanguage { + 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 = GlanceModifier.wallpaperBacking(radius = 8.dp, horizontalPadding = 6.dp) + 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..f5854d2 --- /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.isCrossedOut +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.isCrossedOut) 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. Mirrored by `widget_preview_outside_shade`. */ +private val OUTSIDE_SHADE_DAY = Color.Black.copy(alpha = 0.11f) +private val OUTSIDE_SHADE_NIGHT = Color.Black.copy(alpha = 0.30f) + +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..227405c 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,86 @@ package de.jeanlucmakiola.calendula.widget.month -import de.jeanlucmakiola.calendula.domain.isDeclined -import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration +import de.jeanlucmakiola.calendula.widget.InAppLanguage +import de.jeanlucmakiola.calendula.widget.widgetLocale +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.firstDayOfMonthIndex +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 @@ -78,30 +88,33 @@ import kotlinx.datetime.Month import kotlinx.datetime.TimeZone import kotlinx.datetime.YearMonth import java.time.format.TextStyle as JavaTextStyle -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 = - YearMonth(index / 12, Month(index % 12 + 1)) + firstDayOfMonthIndex(index).let { YearMonth(it.year, it.month) } /** * Month-grid widget: a 6×7 calendar with today highlighted, connected multi-day * 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 +125,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 +135,62 @@ 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 } + InAppLanguage { + 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 +227,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,357 +282,87 @@ 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), - ) - } - } -} - -/** 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 { - val locale = Locale.getDefault() - return (0 until 7).map { i -> - java.time.DayOfWeek.of((weekStart.ordinal + i) % 7 + 1) - .getDisplayName(JavaTextStyle.NARROW, locale) - } -} - -@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( + 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 - .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, + .fillMaxWidth() + .cornerRadius(8.dp) + .background(GlanceTheme.colors.primaryContainer) + .padding(vertical = 1.dp), ) } } } } -/** 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 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 } -/** 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)) - } -} +private fun monthCellRole(): WidgetColorRole = monthCellRole(LocalWidgetBackground.current) -/** 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)) +/** 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 { + val locale = widgetLocale() + return (0 until 7).map { i -> + java.time.DayOfWeek.of((weekStart.ordinal + i) % 7 + 1) + .getDisplayName(JavaTextStyle.NARROW, locale) + } } @Composable @@ -605,17 +379,15 @@ private fun PermissionMessage() { fontSize = 14.sp, textAlign = TextAlign.Center, ), + modifier = GlanceModifier.wallpaperBacking().padding(horizontal = 12.dp, vertical = 8.dp), ) } } -// Locale.getDefault() rather than currentLocale(): Glance has no -// LocalConfiguration, and the widget is re-rendered on a configuration change -// anyway (same convention as AgendaWidget). private fun monthLabel(month: YearMonth, currentYear: Int): String = formatCalendarTitle( date = java.time.LocalDate.of(month.year, month.month.ordinal + 1, 1), - locale = Locale.getDefault(), + locale = widgetLocale(), currentYear = currentYear, skeleton = "LLLL", ) 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..7b9d613 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/quickadd/QuickAddWidget.kt @@ -0,0 +1,95 @@ +package de.jeanlucmakiola.calendula.widget.quickadd + +import de.jeanlucmakiola.calendula.widget.InAppLanguage +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() } + InAppLanguage { + 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..d7e377c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/split/MonthDayWidget.kt @@ -0,0 +1,466 @@ +package de.jeanlucmakiola.calendula.widget.split + +import de.jeanlucmakiola.calendula.domain.isCrossedOut +import de.jeanlucmakiola.calendula.widget.InAppLanguage +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.WidgetSettings +import de.jeanlucmakiola.calendula.widget.widgetLoadContext +import de.jeanlucmakiola.calendula.widget.compactRowMetrics +import de.jeanlucmakiola.calendula.widget.firstDayOfMonthIndex +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.WidgetActionStyle +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, + settings: WidgetSettings? = null, +): Themed { + val ready = loaded.content as? MonthWidgetSource.Ready ?: return Themed(MonthDayData.NeedsPermission, loaded.appTheme) + val settings = 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 -> + val ctx = context.widgetLoadContext(id) + context.loadMonthDay(context.loadCachedMonthSource(id, c, s, ctx), ctx.options, ctx.settings) + } + 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) + InAppLanguage { + 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())) + // The selection moves with the page — to today on this month, else the + // 1st — so the list never shows a day outside the loaded window. + updateAppWidgetState(context, glanceId) { prefs -> + val next = (prefs[MONTH_INDEX_KEY] ?: current) + delta + if (next == current) { + prefs.remove(MONTH_INDEX_KEY) + prefs.remove(SELECTED_KEY) + } else { + prefs[MONTH_INDEX_KEY] = next + prefs[SELECTED_KEY] = firstDayOfMonthIndex(next).toString() + } + } + 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 = firstDayOfMonthIndex(index) + 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) + // Declined and cancelled events are no plan for the day, as on the week strip (#388). + val planned = source.instances.filterNot { it.isCrossedOut } + val busy = groupAgendaDays(days.first(), days.last(), planned, 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 = firstDayOfMonthIndex(index).toJavaLocalDate(), + 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 = firstDayOfMonthIndex(index).month.ordinal + 1 + val backing = GlanceModifier.wallpaperBacking(radius = 14.dp) + 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..584db6c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/today/TodayWidget.kt @@ -0,0 +1,269 @@ +package de.jeanlucmakiola.calendula.widget.today + +import de.jeanlucmakiola.calendula.widget.InAppLanguage +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.Themed +import de.jeanlucmakiola.calendula.widget.WidgetTicks +import de.jeanlucmakiola.calendula.widget.systemZone +import de.jeanlucmakiola.calendula.domain.localEnd +import androidx.glance.appwidget.GlanceAppWidgetManager +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.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.loadPlacedToday(id), refreshStamp(context, id)) + provideContent { + val loaded = widgetData.current(currentRefreshStamp()) { context.loadPlacedToday(id) } + InAppLanguage { + 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) +} + +/** [loadTodayList] for placed widget [id], arming its redraw for when the next of today's events ends. */ +private suspend fun Context.loadPlacedToday(id: GlanceId): Themed { + val loaded = loadTodayList(widgetLoadContext(id)) + val appWidgetId = GlanceAppWidgetManager(this).getAppWidgetId(id) + val data = loaded.content as? DaysWidgetData.Ready + val zone = systemZone() + val nextEnd = data?.eventsOn(data.today)?.map { it.localEnd(zone) }?.filter { it > data.now }?.minOrNull() + if (nextEnd == null) { + WidgetTicks.cancel(this, WidgetKind.TODAY, appWidgetId) + } else { + WidgetTicks.schedule(this, WidgetKind.TODAY, appWidgetId, nextEnd) + } + return loaded +} + +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 = GlanceModifier.wallpaperBacking(radius = 8.dp, horizontalPadding = 8.dp, filledHorizontalPadding = 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..3687e77 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/upcoming/UpcomingWidget.kt @@ -0,0 +1,185 @@ +package de.jeanlucmakiola.calendula.widget.upcoming + +import de.jeanlucmakiola.calendula.widget.InAppLanguage +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.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)) } + InAppLanguage { + 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 = GlanceModifier.wallpaperBacking(radius = 12.dp, horizontalPadding = 8.dp, filledHorizontalPadding = 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..cf856ca --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/week/WeekStripWidget.kt @@ -0,0 +1,204 @@ +package de.jeanlucmakiola.calendula.widget.week + +import de.jeanlucmakiola.calendula.domain.isCrossedOut +import de.jeanlucmakiola.calendula.widget.InAppLanguage +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) } + InAppLanguage { + 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)) + // The gap is padding on each tile rather than a Spacer between them: + // Glance drops a row's children past the tenth, and 7 tiles plus 6 + // spacers would lose the weekend. + is DaysWidgetData.Ready -> Row( + modifier = GlanceModifier.fillMaxSize().padding(horizontal = 6.dp - DayGap / 2, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + for (i in 0 until 7) { + Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight().padding(horizontal = DayGap / 2)) { + 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() + // A declined or cancelled event is no plan for the day, so it adds no dot and no count (#388). + val events = data.eventsOn(date).filterNot { it.isCrossedOut } + val colors = events.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, events.size, label, events.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..06179af --- /dev/null +++ b/app/src/main/res/drawable/preview_widget_outside_bg.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + 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..b111150 --- /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..5963b4a --- /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-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 90a8ab2..0bc5a09 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -456,7 +456,7 @@ استعادة من ملف ‎.ics استيراد الأحداث من نسخة احتياطية أو تطبيق تقويم آخر. النسخ الاحتياطي التلقائي - قم بتصدير تقويماتك المحلية بشكل دوري إلى مجلد كـ ملف ‎.ics. + تصدير تقويماتك المحلية بشكل دوري إلى مجلد كـ ملف ‎.ics مجلد النسخ الاحتياطي اضغط لاختيار مجلد كل %1$s @@ -638,7 +638,7 @@ يتم استيراد %d حدث يتم استيراد %d حدث - نقل الحدث المتكرر + نقل الحدث المتكرّر فقط الحقول التي عدلتها هي التي ستحل محل التغيير الخارجي اسحب لإعادة الترتيب اسحب لإعادة ترتيب طرق العرض المدرجة في قائمة التنقل. @@ -665,7 +665,7 @@ %1$s %2$s إعدادات لأدوات الشاشة الرئيسية وعنصر الإعدادات السريعة. أضف أداة عن طريق الضغط لفترة طويلة على شاشتك الرئيسية. لم يتم استيراد قوائم الضيوف. - معفى من تحسين البطارية — تصل التذكيرات في الوقت المحدد. + معفى من تحسين البطارية — تصل التذكيرات في الوقت المحدّد. توصيل موثوق تنطلق التذكيرات لأحداث التي تستمر طوال اليوم عند %1$s فقط للآن — يعيد التعيين للمدى المحفوظ خاصتك عند إعادة فتح Calendula. @@ -804,4 +804,30 @@ اليوم فتح في الخرائط لا تطبيق يمكنه فتح هذا الرابط + Ko-fi + Liberapay + التبرعات المتكررة + الإكراميات لمرة واحدة + أسود + داكن مع خلفيات سوداء نقية، لشاشات AMOLED + تم التغيير إلى %1$s + تغيير الحدث المتكرّر + إجراءات الحدث + إخفاء إجراءات الحدث + إلغاء تحديد الحدث + إنشاء حدث + تجاهل الحدث الجديد + تعذّر فتح الحدث + المزيد من الإجراءات + تغييرات غير محفوظة + حفظ التغييرات + تجاهل التغييرات + إنشاء + التفاصيل + نموذج التعديل + إنشاء حدث + الأحداث + النقر بحدث يفتح + الأحداث التي لا يمكنك تعديلها وإشعارات التذكير دائمًا تفتح التفاصيل. + متابعة التعديل diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 0d54e65..f385da4 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -708,4 +708,37 @@ Гэты не атрымалася ўключыць. У мэню Налады → Календары знаходзяцца такія самыя перамыкачы. Не атрымалася стварыць каляндар з-за адмовы сховішча вашай прылады. Сёньня + Зьменена на %1$s + Зьмяніць пэрыядычную падзею + Дзеяньні падзеі + Схаваць дзеяньні падзеі + Адмяніць выбар падзеі + Стварыць падзею + Адкінуць новую падзею + Не атрымалася адкрыць падзею + Іншыя дзеяньні + Незахаваныя зьмены + Захаваць зьмены + Адкінуць зьмены + Працягнуць рэдагаваньне + Стварыць + + %d суткі + %d сутак + %d сутак + %d сутак + + Чорная + Цёмная з чыста чорным тлом для экранаў AMOLED + Паказ некалькіх сутак + Сколькі сутак бачныя ў рэжыме паказу некалькіх сутак. Сёньня адкрываецца ў першым слупку. + Задаецца праз бачныя гадзіны + Бачныя гадзіны + Упісваць дыяпазон гадзін на экране пры праглядзе тыдня, некалькіх сутак або адных сутак + Пры адкрыцьці прагляду на экране зьявіцца дыяпазон %1$s—%2$s + Ад + Да + Гадзіна зьверху экрана ў рэжыме прагляду тыдня, некалькіх сутак або сутак. Каб пабачыць ранейшыя гадзіны, трэба прагортка. + Апошняя гадзіна ўнізе экрану ў рэжыме прагляду. Каб пабачыць пазьнейшыя гадзіны трэба прагортка. Таксама зьвядзеньнем пальцаў па экране можна зьмяніць маштаб да наступнага адкрыцьця рэжыму. + Падзеі diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 7a8961a..b5bc653 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -471,7 +471,7 @@ Vidíte připomínky dvakrát? Zobrazuje je i jiná aplikace kalendáře – v jedné z nich je vypněte. Výchozí připomínka Celodenní události - Čas celodenní připomínky + Čas připomínky celodenní události Připomínky na celodenní události se spouštějí v %1$s Upozorní v %1$s Žádná @@ -489,7 +489,7 @@ Doba odložení Kalendáře Správa kalendářů - Vytvořte místní kalendáře; spravujte synchronizované kalendáře + Správa místních a synchronizovaných kalendářů Jazyk Jazyk aplikace Výchozí nastavení systému @@ -515,9 +515,9 @@ Způsob zápisu času v celé aplikaci. Funkce „Automaticky“ se řídí nastavením vašeho systému. Jak program zachází s událostmi, které již skončily. Aplikace převezme barvy z vaší tapety. - Speciální data kontaktů - Zobrazit data kontaktů - Zkopírujte data narozenin a další data svých kontaktů do místních kalendářů. Aplikace čte pouze kontakty v tomto zařízení – nikam se nic nenahrává, vaše kontakty zůstávají beze změny. + Události kontaktů + Zobrazit události kontaktů + Zkopírujte data narozenin či dalších událostí svých kontaktů do místních kalendářů. Aplikace čte pouze kontakty v tomto zařízení – nikam se nic nenahrává, vaše kontakty zůstávají beze změny. Narozeniny Výročí Ostatní data @@ -529,11 +529,11 @@ Zatím nesynchronizováno Naposledy synchronizováno %1$s Barvu a viditelnost jednotlivých kalendářů nastavte v nastavení kalendářů. - Nastavte v části Speciální data kontaktu + Nastavte v části Události kontaktů Pozastaveno Calendula už nemá přístup k vašim kontaktům, tudíž zůstávají bez aktualizace. Udělit přístup - Vypnout data kontaktů? + Vypnout události kontaktů? Tímto se odstraní kalendář „%1$s“ a jeho události. Veškeré připomínky a poznámky, které jste do nich přidali, budou ztraceny. Vypnout Uložit @@ -566,7 +566,7 @@ Pouze pro čtení Není synchronizováno s tímto zařízením Vyplněno z vašich kontaktů - Tento kalendář se plní z vašich kontaktů, takže jej aplikace Calendula při příští synchronizaci znovu vytvoří. Chcete-li jej odstranit, vypněte možnost speciálních dat v nabídce Nastavení → Speciální data. + Tento kalendář se plní ze seznamu vašich kontaktů, takže jej aplikace Calendula při příští synchronizaci znovu vytvoří. Chcete-li jej odstranit, vypněte možnost událostí kontaktů v nabídce Nastavení → Události kontaktů. Synchronizované kalendáře Tyto kontakty pocházejí z účtů ve vašem zařízení. Vytvářejte je a upravujte v příslušné aplikaci. Spravovat v aplikaci @@ -689,7 +689,7 @@ Nepodařilo se otevřít systém pro hlášení problémů. Zpráva je zkopírována ve vaší schránce. Narozeniny Výročí - Speciální data + Ostatní data Narozeniny má {name} ({year}) Výročí má {name} ({year}) {name} @@ -764,4 +764,26 @@ Jednorázové příspěvky Liberapay Pravidelné příspěvky + Změněno na %1$s + Změnit opakující se událost + Akce událostí + Skrýt akce událostí + Zrušit výběr události + Vytvořit událost + Zahodit novou událost + Událost se nepodařilo otevřít + Další akce + Neuložené změny + Uložit změny + Zahodit změny + Pokračovat v úpravách + Vytvořit + Černý + Tmavý s čistě černým pozadím, pro AMOLED obrazovky + Události + Klepnutím na událost se otevře + Podrobnosti + Formulář pro úpravu + U upomínek a událostí, které nelze upravit, se vždy otevře stránka s podrobnostmi. + Vytvořit událost diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index be432c6..b4aed61 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -592,7 +592,7 @@ Monatsansicht Stil der Monatsansicht Seiten - Ein Monat füllt den Bildschirm. Wische nach links oder rechts, um den Monat zu wechseln. + Ein Monat füllt den Bildschirm. Wische nach links oder rechts, um den Monat zu wechseln, oder wische nach oben, um die Monatsagenda anzuzeigen. Fortlaufende Monate Jeder Monat steht unter seiner eigenen Überschrift, mit etwas Abstand zum nächsten. Nahtlose Wochen @@ -727,4 +727,40 @@ Sichtbare Stunden Von Bis + Woche %1$d öffnen + Zeig die Agenda des ganzen Monats + Zu %1$s geändert + Termin Aktionen + Termin Aktionen verstecken + Termin abwählen + Neuen Termin verwerfen + Termin konnte nicht geöffnet werden + Ungespeicherte Änderungen + Änderungen speichern + Änderungen verwerfen + Erstellen + In Maps öffnen + Dunkel mit schwarzem Hintergrund, für AMOLED Bildschirme + Mehrtagesansicht + Bestimmt, wie viele Tage in der Mehrtagesansicht angezeigt werden sollen. Die erste Spalte ist immer der aktuelle Tag. + Durch sichtbares Zeitfenster bestimmt + Zeigt in Wochen-, Mehrtages- und Tagesansicht anstatt aller Stunden ein bestimmtes Zeitfenster an + Ansicht zeigt nur Zeit von %1$s bis %2$s + Die Stunde am oberen Bildschirmrand, wenn die Wochen-, Mehrtages- oder Tagesansicht geöffnet wird. Frühere Stunden können durch Scrollen erreicht werden. + Die Stunde am unteren Bildschirmrand, wenn eine Ansicht geöffnet wird. Spätere Stunden erreichst du weiterhin durch Scrollen, und das Zoomen per Pinch-Geste bleibt bestehen, bis die Ansicht erneut geöffnet wird. + Termine + Aktion beim Tippen auf einen Termin + Details + Termin-Formular + Termine, die nicht bearbeitet werden können, und Erinnerungsbenachrichtigungen öffnen immer die Details. + Einmalige Spenden + Regelmäßige Spenden + Termin erstellen + Ko-fi + Liberapay + Wiederkehrenden Termin verschieben + Neuen Termin erstellen + Weitere Aktionen + Weiter bearbeiten + Zurück diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 0c65517..5211284 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -244,7 +244,7 @@ 12 ore 24 ore Grglia oraria - Mostra una riga separatrice per ogni ora nella vista Settimana e Giorno + Mostra una linea di separazione per ogni ora in una cella a sé stante nella visualizzazione settimanale e giornaliera Attenua gli eventi completati Attenua gli eventi conclusi nella vista Mese e Settimana Eventi passati @@ -422,7 +422,7 @@ Impossibile leggere il file come font Vista Pulsante di cambio rapido - Scegli attraverso quali viste scorrere col pulsante in alto a destra, trascinale per riordinarle. Le viste disattivate rimangono raggiungibili dal menù di navigazione. + Scegli attraverso quali viste scorrere col pulsante in alto a destra, trascinale per riordinarle. Se sono attive meno di due schermate, il pulsante viene nascosto. Le viste disattivate rimangono accessibili dal menu di navigazione. Menù di navigazione Trascina per riordinare le viste elencate nel menù di navigazione. Trascina per riordinare @@ -551,12 +551,14 @@ Seleziona tutti Elimina i selezionati - Eliminare l\'evento? - Eliminare l\'evento? + Eliminare %d l\'evento? + Eliminare %d gli eventi? + Eliminare %d eventi? - Evento eliminato - Evento eliminato + %d evento eliminato + %d eventi eliminati + %d eventi eliminati %1$d eliminati, %2$d no %1$d eliminati, poi permessi di scrittura negati @@ -598,7 +600,7 @@ Vista Mese Stile vista Mese Pagine - Viene mostrato un singolo mese. Scorri a destra o a sinistra per cambiare mese. + Viene mostrato un singolo mese. Scorri a destra o a sinistra per cambiare mese oppure scorri verso l\'alto per visualizzare l\'agenda mensile. Scorrimento Ciò che fa l\'Agenda con eventi conclusi. Deduci il colore dell\'app dallo sfondo. @@ -696,9 +698,9 @@ Impossibile creare il calendario. La memoria del calendario del tuo telefono non lo ha accettato. Oggi - Giorno - Giorni - Giorno + %d giorno + %d giorni + %d giorni Visualizzazione su più giorni La vista più giorni mostra, affiancati, i giorni. La data odierna è visualizzata nella prima colonna. @@ -736,4 +738,30 @@ Non è stato possibile aggiungere gli eventi %d. Non è stato possibile aggiungere l\'evento %d. + Crea evento + Scarta nuovo evento + Impossibile aprire l\'evento + Deseleziona evento + Più azioni + Modifiche non salvate + Salva modifiche + Scarta modifiche + Continua a modificare + Crea + Modificato in %1$s + Modifica evento ricorrente + Azioni evento + Nascondi azioni evento + Nero + Scuro con sfondi completamente neri, per schermi AMOLED + Eventi + Cliccando su un evento si apre + Dettagli + Modifica modulo + Gli eventi che non è possibile modificare e le notifiche di promemoria, aprono sempre la pagina dei dettagli. + Ko-fi + Contributi una tantum + Liberapay + Donazioni ricorrenti + Crea evento 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..b9e9d9a 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,7 @@ - @android:color/system_neutral1_900 + @android:color/system_accent2_700 + @android:color/system_accent2_900 @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..c53c114 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 + #4D000000 #E1E3E6 #A8ADB2 #A3CBE2 diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 57fbe85..e8d60e7 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -716,4 +716,30 @@ Tot Het uur bovenaan het scherm bij het openen van de week-, meerdaagse of dagweergave. Eerdere uren blijven bereikbaar door te scrollen. Het uur onderaan het scherm bij het openen van een weergave. Latere uren blijven bereikbaar door te scrollen, en in- of uitzoomen met twee vingers geldt totdat de weergave opnieuw wordt geopend. + Gewijzigd naar %1$s + Terugkerende gebeurtenis wijzigen + Acties voor gebeurtenis + Acties voor gebeurtenis verbergen + Selectie van gebeurtenis opheffen + Gebeurtenis maken + Nieuwe gebeurtenis verwerpen + Kan de gebeurtenis niet openen + Meer acties + Niet-opgeslagen wijzigingen + Wijzigingen opslaan + Wijzigingen verwerpen + Verder bewerken + Maken + Zwart + Donker met volledig zwarte achtergronden, voor AMOLED-schermen + Gebeurtenissen + Tikken op een gebeurtenis opent + Details + Bewerkformulier + Gebeurtenissen die je niet kunt bewerken en meldingen van herinneringen openen altijd de details. + Ko-fi + Eenmalige bijdragen + Liberapay + Terugkerende donaties + Gebeurtenis maken 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..4500fcc 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,7 @@ - @android:color/system_neutral1_50 + @android:color/system_accent2_100 + @android:color/system_accent2_10 @android:color/system_neutral1_900 @android:color/system_neutral2_700 @android:color/system_accent1_600 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml new file mode 100644 index 0000000..684c4bd --- /dev/null +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -0,0 +1,28 @@ + + + + Minutes + Hours + Days + Weeks + + 一個現代的行事曆。 + 行事曆 + 加載中…… + 重試 + 出錯了。 + 需使用行事曆。 + Calendula會直接從系統行事曆讀取你的活動,即需要有行事曆存取才能顯示行程。 + 授權進入 + 無設定行事曆。 + 在這台裝置新增行事曆帳號,行程將會顯示在這。 + 開放系統行事曆設定 + 你所有的行事曆都關閉了。 + 至少開啟一項行事曆以查看其活動。 + 管理行事曆 + 沒有一個行事曆能加入新行程。 + 被開啟的行事曆是唯讀的、由其他應用程式管理,或未與此裝置同步。開啟一個可以記錄活動的行事曆或新增一個本地活動。 + 該事項已不存在。 + 可能被刪除或被關閉。 + 無法識別該行事曆。 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0ef6379..c5b8f79 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -111,6 +111,7 @@ Calendula needs write access to move events That event no longer exists Can\'t move an event past the end of its series + A cancelled series can only move as a whole, and this would change which days it falls on Cancel OK @@ -154,6 +155,7 @@ days weeks Availability + Status Visibility @@ -252,6 +254,7 @@ Reminders Time zone + Confirmed Tentative Cancelled Free @@ -433,14 +436,105 @@ Upcoming - Calendula agenda - Calendula month + Agenda + Your next events, grouped by day + Month + The month at a glance, with your events + Quick add + Start a new event in one tap + Date + Today\'s date + Week strip + This week, with the days that have events + + + %1$s, %2$d event + %1$s, %2$d events + + Upcoming + Today\'s date over your next events + No upcoming events + Today + Today\'s events, then tomorrow\'s + Nothing left today + Countdown + Time until an event, or the next one + 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 + Month and day + The month and the events of the day you pick + 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 @@ -484,6 +578,8 @@ Currently %1$s Week numbers Show calendar-week numbers in month view + Start times + Show the start time before an event\'s title when there is room Today button in toolbar Show a jump-to-today button in the toolbar instead of a floating button App name @@ -539,14 +635,17 @@ Team review Lunch with Robin Choir practice + + Weekend trip + Dentist Agenda Agenda range How far ahead the Agenda screen lists events. Agenda widget range - How far ahead the agenda home-screen widget lists events. + How far ahead this widget lists events. Agenda widget size - How large the agenda home-screen widget draws its text. The month widget has no setting — its grid always fits itself to the space you give it. + How large this widget draws its text. Small Medium Large @@ -554,7 +653,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 @@ -583,6 +682,8 @@ Split A compact grid marks the days that have events, and the day you tap is listed underneath. Nothing scheduled + Nothing scheduled this month + Nothing else scheduled this month Show the whole month Show the day\'s events Quick-switch button @@ -645,12 +746,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..d131423 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 + #1C000000 #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..0ecdcb5 100644 --- a/app/src/main/res/xml/appwidget_info_agenda.xml +++ b/app/src/main/res/xml/appwidget_info_agenda.xml @@ -8,7 +8,9 @@ android:minResizeHeight="110dp" android:resizeMode="horizontal|vertical" android:widgetCategory="home_screen" - android:description="@string/widget_agenda_label" + android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity" + android:widgetFeatures="reconfigurable" + android:description="@string/widget_agenda_description" android:updatePeriodMillis="1800000" android:previewLayout="@layout/widget_preview_agenda" android:initialLayout="@layout/glance_default_loading_layout" /> 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..ef4ee55 --- /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..8820920 --- /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..ef78262 100644 --- a/app/src/main/res/xml/appwidget_info_month.xml +++ b/app/src/main/res/xml/appwidget_info_month.xml @@ -8,7 +8,9 @@ android:minResizeHeight="150dp" android:resizeMode="horizontal|vertical" android:widgetCategory="home_screen" - android:description="@string/widget_month_label" + android:configure="de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity" + android:widgetFeatures="reconfigurable" + android:description="@string/widget_month_description" android:updatePeriodMillis="1800000" android:previewLayout="@layout/widget_preview_month" android:initialLayout="@layout/glance_default_loading_layout" /> 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..3f6c811 --- /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..281b882 --- /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..05ef690 --- /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..bb06e20 --- /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..9f2f4fe --- /dev/null +++ b/app/src/main/res/xml/appwidget_info_week_strip.xml @@ -0,0 +1,16 @@ + + diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml index f09fe03..372f583 100644 --- a/app/src/main/res/xml/locales_config.xml +++ b/app/src/main/res/xml/locales_config.xml @@ -24,4 +24,5 @@ + diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt index 802d89d..a783baf 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt @@ -4,6 +4,7 @@ import android.provider.CalendarContract import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.domain.AccessLevel import de.jeanlucmakiola.calendula.domain.Availability +import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.FormTime import de.jeanlucmakiola.calendula.domain.realignRecurrence @@ -1112,6 +1113,52 @@ class EventWriteMapperTest { assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, null) } + @Test + fun `a status change on update writes just the status`() { + val original = form() + val values = update(original, original.copy(status = EventStatus.Cancelled)) + assertThat(values).containsExactly(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED) + } + + @Test + fun `an unchanged status writes no status column`() { + val original = form().copy(status = EventStatus.Tentative) + assertThat(update(original, original.copy(title = "Renamed"))) + .doesNotContainKey(CalendarContract.Events.STATUS) + } + + @Test + fun `a new event writes its status only when it is not confirmed`() { + val confirmed = form() + assertThat(buildEventInsertValues(confirmed, "uid", confirmed.toWriteTimes(berlin))) + .doesNotContainKey(CalendarContract.Events.STATUS) + val tentative = form().copy(status = EventStatus.Tentative) + assertThat(buildEventInsertValues(tentative, "uid", tentative.toWriteTimes(berlin))) + .containsEntry(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_TENTATIVE) + } + + @Test + fun `occurrence exception writes the form's status`() { + val values = buildOccurrenceExceptionValues( + form = form().copy(status = EventStatus.Tentative), + originalInstanceMillis = 0L, + zone = berlin, + ) + assertThat(values).containsEntry(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_TENTATIVE) + } + + @Test + fun `no new row is written cancelled`() { + val cancelled = form().copy(status = EventStatus.Cancelled) + assertThat(buildEventInsertValues(cancelled, "uid", cancelled.toWriteTimes(berlin))) + .doesNotContainKey(CalendarContract.Events.STATUS) + assertThat(buildOccurrenceExceptionValues(cancelled, originalInstanceMillis = 0L, zone = berlin)) + .containsEntry(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CONFIRMED) + val snapshot = masterSnapshot().copy(status = CalendarContract.Events.STATUS_CANCELED) + assertThat(buildMovedMasterValues(snapshot, targetCalendarId = 9L, uid = "u@calendula")) + .doesNotContainKey(CalendarContract.Events.STATUS) + } + // --- buildMovedMasterValues (calendar move: verbatim series copy) --- private fun masterSnapshot( diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/ImportedEventValuesTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/ImportedEventValuesTest.kt index 3acd9f4..e84e658 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/ImportedEventValuesTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/ImportedEventValuesTest.kt @@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.calendar import android.provider.CalendarContract import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.domain.EventColorOption +import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent import org.junit.jupiter.api.Test import kotlin.time.Instant @@ -31,6 +32,12 @@ class ImportedEventValuesTest { private fun values(event: ParsedIcsEvent, palette: List = emptyList()) = buildImportedEventValues(event, calendarId = 7L, uid = "u@x", palette = palette) + @Test + fun `a cancelled event is imported confirmed`() { + assertThat(values(allDay().copy(status = EventStatus.Cancelled))[CalendarContract.Events.STATUS]) + .isEqualTo(CalendarContract.Events.STATUS_CONFIRMED) + } + @Test fun `a one-off carries DTEND and no recurrence`() { val v = values(allDay()) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt index 2c58954..3de4ab3 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapperTest.kt @@ -3,7 +3,9 @@ package de.jeanlucmakiola.calendula.data.calendar import android.provider.CalendarContract import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.domain.EventResponse -import de.jeanlucmakiola.calendula.domain.isDeclined +import de.jeanlucmakiola.calendula.domain.EventStatus +import de.jeanlucmakiola.calendula.domain.isCrossedOut +import de.jeanlucmakiola.calendula.domain.isOutlined import kotlin.time.Instant import org.junit.jupiter.api.Test @@ -21,6 +23,7 @@ class InstanceMapperTest { calendarColor: Int = 0xFFAABBCC.toInt(), location: String? = null, selfAttendeeStatus: Int = CalendarContract.Attendees.ATTENDEE_STATUS_NONE, + status: Int? = null, ): MapColumnReader = MapColumnReader( InstanceProjection.IDX_INSTANCE_ID to instanceId, InstanceProjection.IDX_EVENT_ID to eventId, @@ -33,6 +36,7 @@ class InstanceMapperTest { InstanceProjection.IDX_CALENDAR_COLOR to calendarColor, InstanceProjection.IDX_LOCATION to location, InstanceProjection.IDX_SELF_ATTENDEE_STATUS to selfAttendeeStatus, + InstanceProjection.IDX_STATUS to status, ) @Test @@ -98,17 +102,17 @@ class InstanceMapperTest { @Test fun `a declined invitation is marked, any other answer is not`() { - assertThat(reader().toEventInstance()!!.isDeclined).isFalse() + assertThat(reader().toEventInstance()!!.isCrossedOut).isFalse() assertThat( reader(selfAttendeeStatus = CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED) - .toEventInstance()!!.isDeclined, + .toEventInstance()!!.isCrossedOut, ).isTrue() listOf( CalendarContract.Attendees.ATTENDEE_STATUS_ACCEPTED, CalendarContract.Attendees.ATTENDEE_STATUS_TENTATIVE, CalendarContract.Attendees.ATTENDEE_STATUS_INVITED, ).forEach { status -> - assertThat(reader(selfAttendeeStatus = status).toEventInstance()!!.isDeclined).isFalse() + assertThat(reader(selfAttendeeStatus = status).toEventInstance()!!.isCrossedOut).isFalse() } } @@ -129,4 +133,42 @@ class InstanceMapperTest { .isEqualTo(expected) } } + + @Test + fun `the event status is read, a missing one is confirmed`() { + assertThat(reader().toEventInstance()!!.status).isEqualTo(EventStatus.Confirmed) + assertThat(reader(status = CalendarContract.Events.STATUS_TENTATIVE).toEventInstance()!!.status) + .isEqualTo(EventStatus.Tentative) + assertThat(reader(status = CalendarContract.Events.STATUS_CANCELED).toEventInstance()!!.status) + .isEqualTo(EventStatus.Cancelled) + } + + @Test + fun `a cancelled event is crossed out, a tentative one outlined`() { + val cancelled = reader(status = CalendarContract.Events.STATUS_CANCELED).toEventInstance()!! + val tentative = reader(status = CalendarContract.Events.STATUS_TENTATIVE).toEventInstance()!! + assertThat(cancelled.isCrossedOut).isTrue() + assertThat(cancelled.isOutlined).isFalse() + assertThat(tentative.isCrossedOut).isFalse() + assertThat(tentative.isOutlined).isTrue() + } + + @Test + fun `a cancelled unanswered invitation is crossed out rather than outlined`() { + val event = reader( + selfAttendeeStatus = CalendarContract.Attendees.ATTENDEE_STATUS_INVITED, + status = CalendarContract.Events.STATUS_CANCELED, + ).toEventInstance()!! + assertThat(event.isCrossedOut).isTrue() + assertThat(event.isOutlined).isFalse() + } + + @Test + fun `a cancelled one-off from the events table gets a negated id`() { + val inst = reader(instanceId = 7L, eventId = 7L, status = CalendarContract.Events.STATUS_CANCELED) + .toCancelledOneOff() + assertThat(inst?.instanceId).isEqualTo(-7L) + assertThat(inst?.eventId).isEqualTo(7L) + assertThat(inst?.isCrossedOut).isTrue() + } } 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..d2fb8e6 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 @@ -156,6 +156,14 @@ class SettingsPrefsTest { assertThat(prefs.showWeekNumbers.first()).isTrue() } + @Test + fun `month start times default on and round-trips`(@TempDir tempDir: Path) = runTest { + val prefs = testSettings(tempDir) + assertThat(prefs.monthShowStartTimes.first()).isTrue() + prefs.setMonthShowStartTimes(false) + assertThat(prefs.monthShowStartTimes.first()).isFalse() + } + @Test fun `autofocus event title defaults on and round-trips`(@TempDir tempDir: Path) = runTest { val prefs = testSettings(tempDir) @@ -165,15 +173,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/data/reminders/ReminderAlertTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderAlertTest.kt new file mode 100644 index 0000000..3bebbcc --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderAlertTest.kt @@ -0,0 +1,39 @@ +package de.jeanlucmakiola.calendula.data.reminders + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.reminders.PlannedReminder +import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance +import org.junit.jupiter.api.Test + +class ReminderAlertTest { + + private fun instance(eventId: Long = 1L, beginMillis: Long = 1_800_000_000_000L) = ReminderEventInstance( + eventId = eventId, + calendarId = 1L, + beginMillis = beginMillis, + endMillis = beginMillis + 3_600_000L, + title = "Dentist", + location = null, + isAllDay = false, + ) + + private fun alert(instance: ReminderEventInstance, minutes: Int) = + PlannedReminder(instance, minutes, instance.beginMillis - minutes * 60_000L).toAlert() + + @Test + fun `two reminders on one occurrence share a notification`() { + val dayBefore = alert(instance(), 1_440) + val hourBefore = alert(instance(), 60) + + assertThat(dayBefore.key).isNotEqualTo(hourBefore.key) + assertThat(dayBefore.occurrenceKey).isEqualTo(hourBefore.occurrenceKey) + } + + @Test + fun `occurrences of one series keep their own notifications`() { + val first = alert(instance(beginMillis = 1_800_000_000_000L), 60) + val second = alert(instance(beginMillis = 1_800_086_400_000L), 60) + + assertThat(first.occurrenceKey).isNotEqualTo(second.occurrenceKey) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt index 13d20b6..c5dd544 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt @@ -212,4 +212,30 @@ class ReminderTimeTextTest { ) assertThat(text).isEqualTo("Today") } + + @Test + fun `a timed event from local midnight to midnight reads like an all-day one`() { + val oneDay = text( + beginMillis = millisAt(LocalDateTime.of(2026, 6, 12, 0, 0), berlin), + endMillis = millisAt(LocalDateTime.of(2026, 6, 13, 0, 0), berlin), + today = LocalDate.of(2026, 6, 11), + ) + assertThat(oneDay).isEqualTo("Tomorrow") + val twoDays = text( + beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 0, 0), berlin), + endMillis = millisAt(LocalDateTime.of(2026, 6, 13, 0, 0), berlin), + ) + assertThat(twoDays).startsWith("Today – ") + } + + @Test + fun `midnight is read in the device zone`() { + // Midnight in Berlin is 22:00 the day before in UTC, so it is timed there. + val text = text( + beginMillis = millisAt(LocalDateTime.of(2026, 6, 12, 0, 0), berlin), + endMillis = millisAt(LocalDateTime.of(2026, 6, 13, 0, 0), berlin), + zone = ZoneOffset.UTC, + ) + assertThat(text).contains("22:00") + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventDisplayOrderTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventDisplayOrderTest.kt new file mode 100644 index 0000000..56c9e34 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventDisplayOrderTest.kt @@ -0,0 +1,32 @@ +package de.jeanlucmakiola.calendula.domain + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.testEvent +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.plus +import org.junit.jupiter.api.Test +import kotlin.time.Instant + +class EventDisplayOrderTest { + + @Test + fun `whole-day events order by title east and west of UTC`() { + listOf("Europe/Berlin", "America/New_York").map(TimeZone::of).forEach { zone -> + val day = LocalDate(2026, 11, 5) + val allDay = testEvent( + id = 1, title = "B all-day", isAllDay = true, + start = Instant.parse("2026-11-05T00:00:00Z"), end = Instant.parse("2026-11-06T00:00:00Z"), + ) + val timed = testEvent( + id = 2, title = "A timed", + start = day.atStartOfDayIn(zone), end = day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone), + ) + + assertThat(listOf(allDay, timed).sortedForDisplay(zone)).containsExactly(timed, allDay).inOrder() + assertThat(listOf(timed, allDay).sortedForDisplay(zone)).containsExactly(timed, allDay).inOrder() + } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt index cd9f7d5..83cce33 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt @@ -346,6 +346,7 @@ class EventFormTest { rrule = "FREQ=DAILY", availability = Availability.Free, accessLevel = AccessLevel.Private, + status = EventStatus.Tentative, color = 0xFFD50000.toInt(), ) assertThat(full.populatedFields()).containsExactly( @@ -354,6 +355,7 @@ class EventFormTest { EventFormField.Reminders, EventFormField.Recurrence, EventFormField.Availability, + EventFormField.Status, EventFormField.Visibility, EventFormField.Color, ) @@ -570,4 +572,20 @@ class EventFormTest { val started = form.withStart(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(2, 30)), berlin) assertThat(started.start.dateTime).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(3, 30))) } + + @Test + fun `a cancelled form saves only as the whole series`() { + assertThat(form().canSaveApartFromSeries()).isTrue() + assertThat(form().copy(status = EventStatus.Tentative).canSaveApartFromSeries()).isTrue() + assertThat(form().copy(status = EventStatus.Cancelled).canSaveApartFromSeries()).isFalse() + } + + @Test + fun `a copy of a cancelled event starts confirmed, a tentative one stays tentative`() { + val zone = TimeZone.of("Europe/Berlin") + assertThat(testDetail(status = EventStatus.Cancelled).toDuplicateForm(canModify = true, zone).status) + .isEqualTo(EventStatus.Confirmed) + assertThat(testDetail(status = EventStatus.Tentative).toDuplicateForm(canModify = true, zone).status) + .isEqualTo(EventStatus.Tentative) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventInstanceSpanTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventInstanceSpanTest.kt index a3b4ae2..421a449 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventInstanceSpanTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventInstanceSpanTest.kt @@ -82,4 +82,39 @@ class EventInstanceSpanTest { val timed = instance(utc(2026, 7, 19, 12), utc(2026, 7, 19, 13), isAllDay = false) assertThat(timed.dateZone(newYork)).isEqualTo(newYork) } + + @Test + fun `a timed event from local midnight to local midnight covers whole days`() { + // Stored as 22:00Z, the shape ICSx5 gives a date-only feed event (#296). + val oneDay = instance(utc(2026, 7, 18, 22), utc(2026, 7, 19, 22), isAllDay = false) + val twoDays = instance(utc(2026, 7, 18, 22), utc(2026, 7, 20, 22), isAllDay = false) + assertThat(oneDay.coversWholeDays(berlin)).isTrue() + assertThat(twoDays.coversWholeDays(berlin)).isTrue() + } + + @Test + fun `local midnight is read in the device zone`() { + val event = instance(utc(2026, 7, 18, 22), utc(2026, 7, 19, 22), isAllDay = false) + assertThat(event.coversWholeDays(newYork)).isFalse() + } + + @Test + fun `a timed event off midnight at either end does not cover whole days`() { + assertThat(instance(utc(2026, 7, 19), utc(2026, 7, 19, 23), isAllDay = false).coversWholeDays(TimeZone.UTC)) + .isFalse() + assertThat(instance(utc(2026, 7, 19, 1), utc(2026, 7, 20), isAllDay = false).coversWholeDays(TimeZone.UTC)) + .isFalse() + } + + @Test + fun `a zero-length event at midnight does not cover whole days`() { + assertThat(instance(utc(2026, 7, 19), utc(2026, 7, 19), isAllDay = false).coversWholeDays(TimeZone.UTC)) + .isFalse() + } + + @Test + fun `an all-day event covers whole days in any zone`() { + assertThat(allDayJul19().coversWholeDays(newYork)).isTrue() + assertThat(allDayJul19().coversWholeDays(berlin)).isTrue() + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt index 3bef4c9..91f0a17 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventShiftTest.kt @@ -1,5 +1,7 @@ package de.jeanlucmakiola.calendula.domain +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Duration.Companion.hours import com.google.common.truth.Truth.assertThat import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime @@ -195,11 +197,13 @@ class EventShiftTest { } @Test - fun `an all-day event ignores a resize`() { + fun `a resized all-day event becomes timed`() { val allDay = form(isAllDay = true) val start = allDay.start.dateTime.toInstant(berlin) - assertThat(allDay.resizedTo(start, start, berlin)).isEqualTo(allDay) + val timed = allDay.resizedTo(start, start + 1.hours, berlin) + assertThat(timed.isAllDay).isFalse() + assertThat(timed.end.dateTime).isEqualTo(allDay.start.dateTime.toInstant(berlin).plus(1.hours).toLocalDateTime(berlin)) } // 02:00 CEST to 02:00 CET on 2026-10-25: one real hour, one wall-clock value. diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/HasEndedTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/HasEndedTest.kt new file mode 100644 index 0000000..f25f55f --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/HasEndedTest.kt @@ -0,0 +1,40 @@ +package de.jeanlucmakiola.calendula.domain + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.testEvent +import kotlinx.datetime.TimeZone +import org.junit.jupiter.api.Test +import kotlin.time.Instant + +class HasEndedTest { + + private val newYork = TimeZone.of("America/New_York") + + // All-day Oct 28, stored as UTC midnights. + private val holiday = testEvent( + title = "Holiday", + start = Instant.parse("2026-10-28T00:00:00Z"), + end = Instant.parse("2026-10-29T00:00:00Z"), + isAllDay = true, + ) + + @Test + fun `an all-day event runs until local midnight west of UTC`() { + // 20:00 in New York, past the stored UTC end. + assertThat(holiday.hasEnded(Instant.parse("2026-10-29T00:00:00Z"), newYork)).isFalse() + assertThat(holiday.hasEnded(Instant.parse("2026-10-29T04:00:00Z"), newYork)).isTrue() + } + + @Test + fun `an all-day event ends at local midnight east of UTC`() { + val berlin = TimeZone.of("Europe/Berlin") + assertThat(holiday.hasEnded(Instant.parse("2026-10-28T23:30:00Z"), berlin)).isTrue() + } + + @Test + fun `a timed event ends at its own end`() { + val call = holiday.copy(isAllDay = false, end = Instant.parse("2026-10-28T15:00:00Z")) + assertThat(call.hasEnded(Instant.parse("2026-10-28T15:00:00Z"), newYork)).isTrue() + assertThat(call.hasEnded(Instant.parse("2026-10-28T14:59:00Z"), newYork)).isFalse() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderPlanTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderPlanTest.kt index cf17e91..c23f0f2 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderPlanTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/reminders/ReminderPlanTest.kt @@ -419,6 +419,30 @@ class ReminderPlanTest { assertThat(schedule.due).isEmpty() } + @Test + fun `of several reminders due on one occurrence only the latest is posted`() { + val planned = plan( + instances = listOf(instance(1L, beginMillis = now + 20 * minute)), + minutesByEvent = mapOf(1L to listOf(60, 30)), + ) + + val schedule = scheduleReminders( + planned, lastFiredMillis = now - day, nowMillis = now, horizonMillis = now + day, + ) + + assertThat(schedule.due.map { it.minutes }).containsExactly(30) + } + + @Test + fun `two reminders on one occurrence share an occurrence key`() { + val planned = plan( + instances = listOf(instance(1L)), + minutesByEvent = mapOf(1L to listOf(10, 30)), + ) + + assertThat(planned[0].occurrenceKey).isEqualTo(planned[1].occurrenceKey) + } + @Test fun `due reminders come out in occurrence order`() { val planned = plan( diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaTimeLabelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaTimeLabelTest.kt index 97e4942..4c51e9d 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaTimeLabelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaTimeLabelTest.kt @@ -65,4 +65,14 @@ class AgendaTimeLabelTest { assertThat(labelOn(2026, 7, 1, e)).isEqualTo(AgendaTimeLabel.AllDay) assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.Ends(e.end)) } + + @Test + fun `a timed event from midnight to midnight is all-day`() { + val oneDay = event(at(2026, 7, 2), at(2026, 7, 3)) + val twoDays = event(at(2026, 7, 2), at(2026, 7, 4)) + + assertThat(labelOn(2026, 7, 2, oneDay)).isEqualTo(AgendaTimeLabel.AllDay) + assertThat(labelOn(2026, 7, 2, twoDays)).isEqualTo(AgendaTimeLabel.AllDay) + assertThat(labelOn(2026, 7, 3, twoDays)).isEqualTo(AgendaTimeLabel.AllDay) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/DragPieceBoundsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/DragPieceBoundsTest.kt index 5a5777c..398af50 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/DragPieceBoundsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/DragPieceBoundsTest.kt @@ -72,6 +72,13 @@ class DragPieceBoundsTest { assertThat(bounds.height).isEqualTo(MIN_EVENT_FRACTION * hourPx - insetPx * 2) } + @Test + fun `a short piece at the end of the day rises as its block does`() { + val bounds = dragPieceBounds(slice(startMin = 1438, spanMin = 1), hourPx, insetPx = 0f) + assertThat(bounds.top + bounds.height).isWithin(0.01f).of(24 * hourPx) + assertThat(bounds.height).isWithin(0.01f).of(MIN_EVENT_FRACTION * hourPx) + } + @Test fun `an inset taller than the piece leaves no height at all`() { val bounds = dragPieceBounds(slice(startMin = 0, spanMin = 60), hourPx = 4f, insetPx = insetPx) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdgesTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdgesTest.kt index 7a4dbe7..e0f7115 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdgesTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/OutOfViewEdgesTest.kt @@ -78,6 +78,21 @@ class OutOfViewEdgesTest { assertThat(outOf(blip).above).isNull() } + @Test + fun `a short event at midnight is judged by where it rises to`() { + // 23:58–23:59 draws from 23:34, so a viewport ending at 23:40 already shows it. + val late = block(6, 23 * 60 + 58, 23 * 60 + 59) + assertThat(outOfViewEvents(listOf(late), topMin = 1000f, bottomMin = 1420f).below).isNull() + val target = revealScrollTarget(late, ViewportEdge.Bottom, hourPx = 60f, viewportPx = 10, marginPx = 0f, maxScroll = 1440) + assertThat(target).isEqualTo(1440 - 26) + } + + @Test + fun `a short event carrying on past midnight is judged by its own start`() { + val overnight = block(7, 23 * 60 + 58, 24 * 60).copy(continuesAfter = true) + assertThat(outOfViewEvents(listOf(overnight), topMin = 1000f, bottomMin = 1420f).below).isEqualTo(overnight) + } + @Test fun `an event above scrolls to its start less the margin`() { val target = revealScrollTarget(breakfast, ViewportEdge.Top, hourPx = 100f, viewportPx = 700, marginPx = 20f, maxScroll = 1700) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/PageStateCacheTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/PageStateCacheTest.kt index 1266a71..be52eeb 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/PageStateCacheTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/PageStateCacheTest.kt @@ -5,7 +5,9 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import kotlin.math.abs @@ -13,16 +15,10 @@ import kotlin.math.abs @OptIn(ExperimentalCoroutinesApi::class) class PageStateCacheTest { - @Test - fun `the same key shares one state`() = runTest { - val cache = PageStateCache(backgroundScope, 0, { a, b -> abs(a - b) <= 1 }) { flow { emit(it) } } - assertThat(cache.get(5)).isSameInstanceAs(cache.get(5)) - } + private var running = 0 - @Test - fun `a dropped entry stops loading`() = runTest(UnconfinedTestDispatcher()) { - var running = 0 - val cache = PageStateCache(backgroundScope, 0, { a, b -> abs(a - b) <= 1 }) { key -> + private fun TestScope.trackingCache() = + PageStateCache(backgroundScope, -1, { a, b -> abs(a - b) <= 1 }) { key -> flow { running++ try { @@ -33,13 +29,11 @@ class PageStateCacheTest { } } } - backgroundScope.launch { cache.get(0).collect {} } - assertThat(running).isEqualTo(1) - // Far enough away that 0 is let go, even though it is still collected. - cache.get(10) - assertThat(running).isEqualTo(0) - assertThat(cache.size).isEqualTo(1) + @Test + fun `the same key shares one state`() = runTest { + val cache = trackingCache() + assertThat(cache.get(5)).isSameInstanceAs(cache.get(5)) } @Test @@ -50,4 +44,63 @@ class PageStateCacheTest { cache.get(6) assertThat(cache.size).isEqualTo(3) } + + @Test + fun `an entry loads only while collected`() = runTest(UnconfinedTestDispatcher()) { + val cache = trackingCache() + val state = cache.get(0) + assertThat(running).isEqualTo(0) + + val page = backgroundScope.launch { state.collect {} } + assertThat(running).isEqualTo(1) + assertThat(state.value).isEqualTo(0) + + page.cancel() + advanceTimeBy(5_001) + assertThat(running).isEqualTo(0) + } + + @Test + fun `an unwatched entry far away is forgotten`() = runTest(UnconfinedTestDispatcher()) { + val cache = trackingCache() + cache.get(0) + cache.get(10) + assertThat(cache.size).isEqualTo(1) + } + + @Test + fun `an entry a page still collects is kept however far away`() = runTest(UnconfinedTestDispatcher()) { + val cache = trackingCache() + backgroundScope.launch { cache.get(0).collect {} } + + cache.get(10) + assertThat(running).isEqualTo(1) + assertThat(cache.size).isEqualTo(2) + } + + @Test + fun `a forgotten entry a page holds still loads once collected`() = runTest(UnconfinedTestDispatcher()) { + val cache = trackingCache() + // The page took its state but hasn't started collecting when 0 is let go. + val held = cache.get(0) + cache.get(10) + + val values = mutableListOf() + backgroundScope.launch { held.collect { values += it } } + assertThat(running).isEqualTo(1) + assertThat(values.last()).isEqualTo(0) + } + + @Test + fun `an entry restarts after the screen comes back`() = runTest(UnconfinedTestDispatcher()) { + val cache = trackingCache() + val held = cache.get(0) + backgroundScope.launch { held.collect {} }.cancel() + advanceTimeBy(5_001) + cache.get(10) + assertThat(running).isEqualTo(0) + + backgroundScope.launch { held.collect {} } + assertThat(running).isEqualTo(1) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RenderedSpanTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RenderedSpanTest.kt new file mode 100644 index 0000000..e575263 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RenderedSpanTest.kt @@ -0,0 +1,52 @@ +package de.jeanlucmakiola.calendula.ui.common + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** A short block near midnight keeps its whole floor rather than running off the day (#483). */ +class RenderedSpanTest { + + private val floor = MIN_EVENT_FRACTION * 60f + + @Test + fun `a long block draws at its own times`() { + assertThat(renderedSpan(540, 600, continuesAfter = false)).isEqualTo(RenderedSpan(540f, 600f)) + } + + @Test + fun `a short block in the day is floored downwards`() { + val span = renderedSpan(20 * 60 + 58, 20 * 60 + 59, continuesAfter = false) + assertThat(span).isEqualTo(RenderedSpan(1258f, 1258f + floor)) + } + + @Test + fun `a short block at the end of the day rises to end at midnight`() { + val span = renderedSpan(23 * 60 + 58, 23 * 60 + 59, continuesAfter = false) + assertThat(span).isEqualTo(RenderedSpan(1440f - floor, 1440f)) + } + + @Test + fun `a block ending at midnight rises too`() { + val span = renderedSpan(23 * 60 + 59, 1440, continuesAfter = false) + assertThat(span).isEqualTo(RenderedSpan(1440f - floor, 1440f)) + } + + @Test + fun `an instant event at the end of the day rises`() { + val span = renderedSpan(1439, 1439, continuesAfter = false) + assertThat(span.bottomMin).isEqualTo(1440f) + assertThat(span.bottomMin - span.topMin).isEqualTo(floor) + } + + @Test + fun `a block that just fits stays put`() { + val start = 1440 - 26 + assertThat(renderedSpan(start, start + 5, continuesAfter = false)).isEqualTo(RenderedSpan(start.toFloat(), 1440f)) + } + + @Test + fun `a block carrying on past midnight keeps its start`() { + val span = renderedSpan(23 * 60 + 58, 1440, continuesAfter = true) + assertThat(span.topMin).isEqualTo(1438f) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt index 93ef853..e44e6f3 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RescheduleViewModelTest.kt @@ -6,6 +6,7 @@ import de.jeanlucmakiola.calendula.data.time.FakeZoneSource import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventDetail +import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.testDetail import de.jeanlucmakiola.calendula.testEvent @@ -27,6 +28,8 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension import org.junit.jupiter.api.io.TempDir import java.nio.file.Path +import kotlinx.datetime.atStartOfDayIn +import kotlin.time.Duration.Companion.hours import kotlin.time.Instant /** @@ -63,6 +66,7 @@ class RescheduleViewModelTest { isAllDay: Boolean = false, isException: Boolean = false, anchorMillis: Long = beginMillis, + status: EventStatus = EventStatus.Confirmed, ): EventDetail = testDetail( instance = testEvent( id = 42L, title = "Standup", @@ -72,6 +76,7 @@ class RescheduleViewModelTest { ), rrule = rrule, isException = isException, + status = status, ) private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): RescheduleViewModel { @@ -671,4 +676,109 @@ class RescheduleViewModelTest { assertThat(vm.scopePrompt.value) .isEqualTo(MoveScopePrompt(occurrenceOnly = true, resize = true)) } + + @Test + fun `a cancelled series moves as a whole without asking`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { + eventDetailResult = { detail(rrule = "FREQ=WEEKLY", status = EventStatus.Cancelled) } + } + val vm = viewModel(tempDir, fake) + + vm.move(oneHourLater()) + advanceUntilIdle() + + assertThat(vm.scopePrompt.value).isNull() + assertThat(fake.updatedEvents).hasSize(1) + assertThat(fake.updatedOccurrences).isEmpty() + assertThat(fake.updatedFromOccurrences).isEmpty() + } + + @Test + fun `a cancelled series whose rule cannot be realigned is not moved at all`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val fake = FakeCalendarDataSource().apply { + eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO,WE", status = EventStatus.Cancelled) } + } + val vm = viewModel(tempDir, fake) + + vm.move(toWednesday()) + advanceUntilIdle() + + assertThat(vm.scopePrompt.value).isNull() + assertThat(vm.outcome.value).isEqualTo(MoveOutcome.BlockedCancelledSeries) + assertThat(vm.abandoned.value).isEqualTo(1) + assertThat(fake.updatedEvents).isEmpty() + assertThat(fake.updatedOccurrences).isEmpty() + } + + @Test + fun `an all-day event dropped into the time grid becomes timed`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val dayStart = monday.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() + val fake = FakeCalendarDataSource().apply { + eventDetailResult = { + testDetail( + instance = testEvent( + id = 42L, title = "Offsite", isAllDay = true, + start = Instant.fromEpochMilliseconds(dayStart), + end = Instant.fromEpochMilliseconds(dayStart + 86_400_000L), + ), + ) + } + } + val vm = viewModel(tempDir, fake) + val start = Instant.fromEpochMilliseconds(beginMillis) + + vm.move( + MoveRequest( + eventId = 42L, + beginMillis = dayStart, + endMillis = dayStart + 86_400_000L, + target = MoveTarget.Span(start, Instant.fromEpochMilliseconds(endMillis)), + ), + ) + advanceUntilIdle() + + val updated = fake.updatedEvents.single().third + assertThat(updated.isAllDay).isFalse() + assertThat(updated.start.dateTime).isEqualTo(LocalDateTime(monday, LocalTime(12, 0))) + assertThat(updated.end.dateTime).isEqualTo(LocalDateTime(monday, LocalTime(13, 0))) + } + + @Test + fun `a recurring all-day event dropped into the grid early on its own day can still move the series`( + @TempDir tempDir: Path, + ) = runTest(dispatcher) { + val dayStart = monday.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() + val fake = FakeCalendarDataSource().apply { + eventDetailResult = { + testDetail( + instance = testEvent( + id = 42L, title = "Offsite", isAllDay = true, + start = Instant.fromEpochMilliseconds(dayStart), + end = Instant.fromEpochMilliseconds(dayStart + 86_400_000L), + ), + rrule = "FREQ=WEEKLY;BYDAY=MO", + ) + } + } + val vm = viewModel(tempDir, fake) + val eight = LocalDateTime(monday, LocalTime(8, 0)).toInstant(TimeZone.currentSystemDefault()) + + vm.move( + MoveRequest( + eventId = 42L, + beginMillis = dayStart, + endMillis = dayStart + 86_400_000L, + target = MoveTarget.Span(eight, eight + 1.hours), + ), + ) + advanceUntilIdle() + + assertThat(vm.scopePrompt.value).isEqualTo(MoveScopePrompt(occurrenceOnly = false)) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimedBlockShapeTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimedBlockShapeTest.kt index 3a78179..b70fcf0 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimedBlockShapeTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimedBlockShapeTest.kt @@ -37,7 +37,7 @@ class TimedBlockShapeTest { val block = layoutDay(listOf(ev), day1, zone).single() assertThat(block.continuesBefore(day1, zone)).isFalse() - assertThat(block.continuesAfter(day1, zone)).isFalse() + assertThat(block.continuesAfter).isFalse() } @Test @@ -46,11 +46,11 @@ class TimedBlockShapeTest { val head = layoutDay(listOf(ev), day1, zone).single() assertThat(head.continuesBefore(day1, zone)).isFalse() - assertThat(head.continuesAfter(day1, zone)).isTrue() + assertThat(head.continuesAfter).isTrue() val tail = layoutDay(listOf(ev), day2, zone).single() assertThat(tail.continuesBefore(day2, zone)).isTrue() - assertThat(tail.continuesAfter(day2, zone)).isFalse() + assertThat(tail.continuesAfter).isFalse() } @Test @@ -59,7 +59,7 @@ class TimedBlockShapeTest { val middle = layoutDay(listOf(ev), day2, zone).single() assertThat(middle.continuesBefore(day2, zone)).isTrue() - assertThat(middle.continuesAfter(day2, zone)).isTrue() + assertThat(middle.continuesAfter).isTrue() } @Test @@ -67,7 +67,7 @@ class TimedBlockShapeTest { val ev = event(at(day1, 20), at(day2, 0)) val block = layoutDay(listOf(ev), day1, zone).single() - assertThat(block.continuesAfter(day1, zone)).isFalse() + assertThat(block.continuesAfter).isFalse() } @Test diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineHoursTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineHoursTest.kt index f7c0610..53d493b 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineHoursTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineHoursTest.kt @@ -45,6 +45,14 @@ class TimelineHoursTest { .isEqualTo(280) } + @Test + fun `the clearance under midnight is not counted as day`() { + // 40px an hour plus 100px of clearance over a 600px viewport: 460px of scroll. + assertThat( + startHourScroll(maxValue = 460, viewportPx = 600, startHour = 7, hourPx = 40f, clearancePx = 100), + ).isEqualTo(280) + } + @Test fun `a layout still at the previous scale is waited out`() { // Still 56px an hour: 1344 - 600 = 744 of scroll. diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineScaleTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineScaleTest.kt index 15b1e19..456419d 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineScaleTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimelineScaleTest.kt @@ -40,6 +40,19 @@ class TimelineScaleTest { assertThat(h * 24).isGreaterThan(phoneViewport * 0.9f) } + @Test + fun `fit-day leaves the FAB clearance under midnight`() { + val viewport = 800.dp + val h = TimelineScale.FitDay.hourHeight(dayFitHeight(viewport)) + assertThat(h * 24 + FabClearance).isAtMost(viewport) + assertThat(h * 24 + FabClearance).isGreaterThan(viewport * 0.99f) + } + + @Test + fun `the fitted height never goes negative`() { + assertThat(dayFitHeight(40.dp)).isEqualTo(0.dp) + } + @Test fun `fit-day clamps instead of shrinking hours past legibility`() { // A very short viewport (split screen, tiny device) would otherwise give diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt index c05cd3a..3f9bcc7 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt @@ -1,5 +1,7 @@ package de.jeanlucmakiola.calendula.ui.month +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.testEvent import de.jeanlucmakiola.calendula.ui.common.startOfWeek import com.google.common.truth.Truth.assertThat import kotlinx.datetime.DateTimeUnit @@ -10,6 +12,7 @@ import kotlinx.datetime.TimeZone import kotlinx.datetime.YearMonth import kotlinx.datetime.minus import kotlinx.datetime.plus +import kotlinx.datetime.yearMonth import org.junit.jupiter.api.Test /** @@ -34,22 +37,22 @@ class ContinuousMonthIndexTest { @Test fun `index round-trips back to its month`() { listOf( - YearMonth(1900, Month.JANUARY), + YearMonth(1600, Month.JANUARY), jun26, YearMonth(2026, Month.DECEMBER), - YearMonth(2100, Month.DECEMBER), + YearMonth(2399, Month.DECEMBER), ).forEach { ym -> assertThat(yearMonthForIndex(monthIndexOf(ym))).isEqualTo(ym) } } @Test - fun `indices are non-negative and span 1900 through 2100`() { - assertThat(monthIndexOf(YearMonth(1900, Month.JANUARY))).isEqualTo(0) + fun `indices are non-negative and span 1600 through 2399`() { + assertThat(monthIndexOf(YearMonth(1600, Month.JANUARY))).isEqualTo(0) assertThat(monthIndexOf(jun26)).isGreaterThan(0) - assertThat(monthIndexOf(YearMonth(2100, Month.DECEMBER))) + assertThat(monthIndexOf(YearMonth(2399, Month.DECEMBER))) .isEqualTo(continuousMonthCount() - 1) - assertThat(continuousMonthCount()).isEqualTo(201 * 12) + assertThat(continuousMonthCount()).isEqualTo(800 * 12) } @Test @@ -154,9 +157,9 @@ class ContinuousMonthIndexTest { DayOfWeek.entries.forEach { ws -> val index = weekIndexOf(wed, ws) assertThat(weekStartForIndex(index, ws)).isEqualTo(wed.startOfWeek(ws)) - assertThat(weekIndexOf(LocalDate(1900, 1, 1), ws)).isAtLeast(0) + assertThat(weekIndexOf(LocalDate(1600, 1, 1), ws)).isAtLeast(0) assertThat(index).isLessThan(continuousWeekCount(ws)) - assertThat(weekIndexOf(LocalDate(2100, 12, 31), ws)) + assertThat(weekIndexOf(LocalDate(2399, 12, 31), ws)) .isLessThan(continuousWeekCount(ws)) } } @@ -236,4 +239,68 @@ class ContinuousMonthIndexTest { assertThat(agendaStartForMonth(YearMonth(2025, Month.JUNE), today)) .isEqualTo(LocalDate(2025, 6, 1)) } + + private fun days(month: YearMonth, today: LocalDate, byDay: Map>, showToday: Boolean = true) = + splitAgendaDays(month, today, byDay, showToday).map { it.date } + + @Test + fun `the split agenda skips a free first day of another month`() { + val today = LocalDate(2026, 6, 10) + val byDay = mapOf( + LocalDate(2026, 7, 1) to emptyList(), + LocalDate(2026, 7, 3) to listOf(testEvent()), + ) + assertThat(days(YearMonth(2026, Month.JULY), today, byDay)).containsExactly(LocalDate(2026, 7, 3)) + } + + @Test + fun `the split agenda keeps a free today`() { + val today = LocalDate(2026, 6, 10) + val byDay = mapOf(LocalDate(2026, 6, 12) to listOf(testEvent())) + assertThat(days(jun26, today, byDay)).containsExactly(today, LocalDate(2026, 6, 12)).inOrder() + } + + @Test + fun `the split agenda drops a free today when the agenda would`() { + val today = LocalDate(2026, 6, 10) + val byDay = mapOf(LocalDate(2026, 6, 12) to listOf(testEvent())) + assertThat(days(jun26, today, byDay, showToday = false)).containsExactly(LocalDate(2026, 6, 12)) + } + + @Test + fun `the split agenda leaves out past days and the grid's other months`() { + val today = LocalDate(2026, 6, 10) + val byDay = mapOf( + LocalDate(2026, 5, 31) to listOf(testEvent()), + LocalDate(2026, 6, 9) to listOf(testEvent()), + LocalDate(2026, 6, 10) to listOf(testEvent()), + LocalDate(2026, 7, 1) to listOf(testEvent()), + ) + assertThat(days(jun26, today, byDay)).containsExactly(today) + assertThat(days(YearMonth(2026, Month.JULY), today, byDay)).containsExactly(LocalDate(2026, 7, 1)) + } + + @Test + fun `an empty month lists no days`() { + assertThat(days(YearMonth(2026, Month.JULY), LocalDate(2026, 6, 10), emptyMap())).isEmpty() + } + + @Test + fun `a date before 1600 is held at the first day of the grid`() { + val held = coerceToMonthRange(LocalDate(1599, 12, 31)) + assertThat(held).isEqualTo(LocalDate(1600, 1, 1)) + assertThat(monthIndexOf(held.yearMonth)).isEqualTo(0) + } + + @Test + fun `a date after 2399 is held at the last day of the grid`() { + val held = coerceToMonthRange(LocalDate(2400, 1, 1)) + assertThat(held).isEqualTo(LocalDate(2399, 12, 31)) + assertThat(monthIndexOf(held.yearMonth)).isEqualTo(continuousMonthCount() - 1) + } + + @Test + fun `a date inside the span is left alone`() { + assertThat(coerceToMonthRange(LocalDate(2026, 6, 10))).isEqualTo(LocalDate(2026, 6, 10)) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTimeTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTimeTest.kt index ee272b2..14f16db 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTimeTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthChipTimeTest.kt @@ -47,4 +47,40 @@ class MonthChipTimeTest { val time = monthChipTime(timed(9, 5), continuesLeft = true, zone, is24Hour = true, locale) assertThat(time).isNull() } + + @Test + fun `switched off, no chip shows a time however wide`() { + assertThat(monthChipShowsTime(enabled = false, availableWidth = 1000f, labelWidth = 10f)).isFalse() + } + + @Test + fun `the time shows only while the label fits beside it`() { + assertThat(monthChipShowsTime(enabled = true, availableWidth = 80f, labelWidth = 80f)).isTrue() + assertThat(monthChipShowsTime(enabled = true, availableWidth = 79f, labelWidth = 80f)).isFalse() + } + + @Test + fun `a short title has to fit whole`() { + assertThat(monthTitleToKeep("Lunch")).isEqualTo("Lunch") + assertThat(monthTitleToKeep("Appointments")).isEqualTo("Appointments") + } + + @Test + fun `a long title only has to keep its first twelve characters`() { + assertThat(monthTitleToKeep("Quarterly planning review")).isEqualTo("Quarterly pl") + } + + @Test + fun `an emoji at the cut is kept whole`() { + val title = "Birthday 🎂 party at home" + val kept = monthTitleToKeep(title) + assertThat(kept).isEqualTo("Birthday 🎂 p") + assertThat(Character.isHighSurrogate(kept.last())).isFalse() + } + + @Test + fun `an emoji as the twelfth character is not split`() { + val kept = monthTitleToKeep("Team dinner🍕 tonight") + assertThat(kept).isEqualTo("Team dinner🍕") + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt index 54b1df5..85cd985 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt @@ -321,4 +321,18 @@ class MonthLayoutTest { private fun declinedTimed(date: LocalDate) = timed(date, 7, 8, id = 4L, title = "Declined early") .copy(response = EventResponse.Declined) + + @Test + fun `a timed event from midnight to midnight is a bar, not a timed chip`() { + val ev = timedEvent( + LocalDate(2026, 6, 10), 0, 0, zone = zone, id = 3L, + endDate = LocalDate(2026, 6, 11), + ) + val week = layoutCalendarWeek(weekOf8th, listOf(ev), zone) + + assertThat(week.spans.map { it.event }).containsExactly(ev) + assertThat(week.spans.single().startCol).isEqualTo(2) + assertThat(week.spans.single().endCol).isEqualTo(2) + assertThat(week.timedByDay.values.flatten()).isEmpty() + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModelRangeTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModelRangeTest.kt new file mode 100644 index 0000000..fb1e12d --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModelRangeTest.kt @@ -0,0 +1,75 @@ +package de.jeanlucmakiola.calendula.ui.month + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.MainDispatcherExtension +import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource +import de.jeanlucmakiola.calendula.data.time.TodaySource +import de.jeanlucmakiola.calendula.testPrefs +import de.jeanlucmakiola.calendula.testRepository +import de.jeanlucmakiola.calendula.testSettings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.YearMonth +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path + +/** The month view never leaves the span its pager can show (#457). */ +@OptIn(ExperimentalCoroutinesApi::class) +class MonthViewModelRangeTest { + + @JvmField @RegisterExtension + val mainDispatcher = MainDispatcherExtension() + private val dispatcher = mainDispatcher.dispatcher + + private fun viewModel(tempDir: Path): MonthViewModel { + val settings = testSettings(tempDir, CoroutineScope(dispatcher)) + val repo = testRepository( + FakeCalendarDataSource(), tempDir, dispatcher, + prefs = testPrefs(tempDir, CoroutineScope(dispatcher)), + settings = settings, + ) + val today = object : TodaySource { + override val today = flowOf(LocalDate(2026, 11, 2)) + } + return MonthViewModel(repo, settings, today, dispatcher) + } + + @Test + fun `a focus before 1600 opens the first month`(@TempDir tempDir: Path) = runTest(dispatcher) { + val vm = viewModel(tempDir) + vm.goToDate(LocalDate(1599, 12, 31)) + assertThat(vm.month.value).isEqualTo(YearMonth(1600, Month.JANUARY)) + assertThat(vm.selectedDate.value).isEqualTo(LocalDate(1600, 1, 1)) + } + + @Test + fun `a focus after 2399 opens the last month`(@TempDir tempDir: Path) = runTest(dispatcher) { + val vm = viewModel(tempDir) + vm.goToDate(LocalDate(2400, 1, 1)) + assertThat(vm.month.value).isEqualTo(YearMonth(2399, Month.DECEMBER)) + } + + @Test + fun `a tap on a day past the span is ignored`(@TempDir tempDir: Path) = runTest(dispatcher) { + val vm = viewModel(tempDir) + vm.goToDate(LocalDate(1600, 1, 1)) + vm.selectDate(LocalDate(1599, 12, 31)) + assertThat(vm.month.value).isEqualTo(YearMonth(1600, Month.JANUARY)) + assertThat(vm.selectedDate.value).isEqualTo(LocalDate(1600, 1, 1)) + } + + @Test + fun `the split pane stops at the first month`(@TempDir tempDir: Path) = runTest(dispatcher) { + val vm = viewModel(tempDir) + vm.goToDate(LocalDate(1600, 1, 15)) + vm.clearSelection() + vm.stepSplitPane(-1) + assertThat(vm.month.value).isEqualTo(YearMonth(1600, Month.JANUARY)) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/PageNumberingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/PageNumberingTest.kt index 93a5814..10422a1 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/PageNumberingTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/PageNumberingTest.kt @@ -10,8 +10,9 @@ import kotlinx.datetime.plus import org.junit.jupiter.api.Test /** - * Pins each grid's page numbering to the formulas it shipped with: a pager - * restores its page by number, so a change here moves the user to another date. + * Pins each grid's page numbering: a pager restores its page by number, so a + * change here moves the user to another date. Page 0 holds the first day of + * the shared span, and the last page holds its last day (#457). */ class PageNumberingTest { @@ -26,6 +27,7 @@ class PageNumberingTest { add(e) e = e.plus(1, DateTimeUnit.DAY) } + addAll(listOf(FIRST, FIRST.minus(1, DateTimeUnit.DAY), LAST, LAST.plus(1, DateTimeUnit.DAY))) } private fun pages(count: Int): List = @@ -36,13 +38,14 @@ class PageNumberingTest { fun `week pages keep their numbering for every first day`() { DayOfWeek.entries.forEach { firstDay -> val grid = DaysGrid.weeks(firstDay) + val phase = DAYS_EPOCH.daysUntil(WEEK_EPOCH.weekStart(firstDay)) assertThat(grid.dayCount).isEqualTo(7) - assertThat(grid.pageCount).isEqualTo(LEGACY_WEEK_COUNT) - pages(LEGACY_WEEK_COUNT).forEach { page -> - assertThat(grid.startForPage(page)).isEqualTo(legacyWeekStart(page, firstDay)) + assertThat(grid.pageCount).isEqualTo(expectedCount(7, phase)) + pages(grid.pageCount).forEach { page -> + assertThat(grid.startForPage(page)).isEqualTo(expectedStart(page, 7, phase)) } dates.forEach { date -> - assertThat(grid.pageFor(date)).isEqualTo(legacyWeekPage(date, firstDay)) + assertThat(grid.pageFor(date)).isEqualTo(expectedPage(date, 7, phase)) } } } @@ -52,12 +55,12 @@ class PageNumberingTest { (1..7).forEach { count -> (-count..count * 2).forEach { phase -> val grid = DaysGrid(count, phase) - assertThat(grid.pageCount).isEqualTo(LEGACY_DAYS_COUNT) - pages(LEGACY_DAYS_COUNT).forEach { page -> - assertThat(grid.startForPage(page)).isEqualTo(legacyDaysStart(page, count, phase)) + assertThat(grid.pageCount).isEqualTo(expectedCount(count, phase)) + pages(grid.pageCount).forEach { page -> + assertThat(grid.startForPage(page)).isEqualTo(expectedStart(page, count, phase)) } dates.forEach { date -> - assertThat(grid.pageFor(date)).isEqualTo(legacyDaysPage(date, count, phase)) + assertThat(grid.pageFor(date)).isEqualTo(expectedPage(date, count, phase)) } } dates.filter { it.year in 1950..2050 }.forEach { date -> @@ -71,12 +74,21 @@ class PageNumberingTest { fun `day pages keep their numbering`() { val grid = DaysGrid.days assertThat(grid.dayCount).isEqualTo(1) - assertThat(grid.pageCount).isEqualTo(LEGACY_DAY_COUNT) - pages(LEGACY_DAY_COUNT).forEach { page -> - assertThat(grid.startForPage(page)).isEqualTo(legacyDayStart(page)) + assertThat(grid.pageCount).isEqualTo(FIRST.daysUntil(LAST) + 1) + pages(grid.pageCount).forEach { page -> + assertThat(grid.startForPage(page)).isEqualTo(FIRST.plus(page, DateTimeUnit.DAY)) } dates.forEach { date -> - assertThat(grid.pageFor(date)).isEqualTo(legacyDayPage(date)) + assertThat(grid.pageFor(date)).isEqualTo(FIRST.daysUntil(date).coerceIn(0, grid.pageCount - 1)) + } + } + + @Test + fun `every grid reaches both ends of the span`() { + (DayOfWeek.entries.map { DaysGrid.weeks(it) } + DaysGrid.days + (1..7).map { DaysGrid(it, 0) }).forEach { grid -> + assertThat(grid.startForPage(grid.pageFor(FIRST))).isAtMost(FIRST) + val lastStart = grid.startForPage(grid.pageFor(LAST)) + assertThat(lastStart.plus(grid.dayCount - 1, DateTimeUnit.DAY)).isAtLeast(LAST) } } @@ -104,34 +116,28 @@ class PageNumberingTest { } private companion object { - const val LEGACY_WEEK_COUNT = 20_000 - const val LEGACY_DAYS_COUNT = 40_000 - const val LEGACY_DAY_COUNT = 140_000 + val FIRST = LocalDate(1600, 1, 1) + val LAST = LocalDate(2399, 12, 31) val WEEK_EPOCH = LocalDate(2000, 1, 3) val DAYS_EPOCH = LocalDate(2000, 1, 1) fun LocalDate.weekStart(firstDay: DayOfWeek): LocalDate = minus(((dayOfWeek.ordinal - firstDay.ordinal) + 7) % 7, DateTimeUnit.DAY) - fun legacyWeekStart(page: Int, firstDay: DayOfWeek): LocalDate = - WEEK_EPOCH.weekStart(firstDay).plus((page - LEGACY_WEEK_COUNT / 2) * 7, DateTimeUnit.DAY) - - fun legacyWeekPage(date: LocalDate, firstDay: DayOfWeek): Int { - val days = WEEK_EPOCH.weekStart(firstDay).daysUntil(date.weekStart(firstDay)) - return (LEGACY_WEEK_COUNT / 2 + days / 7).coerceIn(0, LEGACY_WEEK_COUNT - 1) + /** Page 0 starts on the page boundary at or before [FIRST]. */ + fun firstStart(count: Int, phase: Int): LocalDate { + val origin = DAYS_EPOCH.plus(phase, DateTimeUnit.DAY) + return origin.plus(Math.floorDiv(origin.daysUntil(FIRST), count) * count, DateTimeUnit.DAY) } - fun legacyDaysStart(page: Int, count: Int, phase: Int): LocalDate = - DAYS_EPOCH.plus(phase + (page - LEGACY_DAYS_COUNT / 2) * count, DateTimeUnit.DAY) + fun expectedStart(page: Int, count: Int, phase: Int): LocalDate = + firstStart(count, phase).plus(page * count, DateTimeUnit.DAY) - fun legacyDaysPage(date: LocalDate, count: Int, phase: Int): Int = - (LEGACY_DAYS_COUNT / 2 + Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, count)) - .coerceIn(0, LEGACY_DAYS_COUNT - 1) + fun expectedCount(count: Int, phase: Int): Int = + Math.floorDiv(firstStart(count, phase).daysUntil(LAST), count) + 1 - fun legacyDayStart(page: Int): LocalDate = - DAYS_EPOCH.plus(page - LEGACY_DAY_COUNT / 2, DateTimeUnit.DAY) - - fun legacyDayPage(date: LocalDate): Int = - (LEGACY_DAY_COUNT / 2 + DAYS_EPOCH.daysUntil(date)).coerceIn(0, LEGACY_DAY_COUNT - 1) + fun expectedPage(date: LocalDate, count: Int, phase: Int): Int = + Math.floorDiv(firstStart(count, phase).daysUntil(date), count) + .coerceIn(0, expectedCount(count, phase) - 1) } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt index b37a2d6..7b61872 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/week/WeekLayoutTest.kt @@ -1,7 +1,9 @@ package de.jeanlucmakiola.calendula.ui.week +import de.jeanlucmakiola.calendula.ui.common.StripDrag import de.jeanlucmakiola.calendula.ui.common.startOfWeek import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.testEvent import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek @@ -106,6 +108,42 @@ class WeekLayoutTest { assertThat(blocks.all { it.lane == 0 && it.laneCount == 1 }).isTrue() } + @Test + fun `two short entries near midnight sit side by side`() { + // Both lift to end at midnight, so on real times alone the later one would hide the earlier (#483). + val a = event(at(wed, 23, 40), at(wed, 23, 41), id = 1L) + val b = event(at(wed, 23, 55), at(wed, 23, 56), id = 2L) + val blocks = layoutDay(listOf(a, b), wed, zone).sortedBy { it.lane } + assertThat(blocks.map { it.event.instanceId }).containsExactly(1L, 2L).inOrder() + assertThat(blocks.all { it.laneCount == 2 }).isTrue() + } + + @Test + fun `a short entry lifted into the event before it takes its own lane`() { + val a = event(at(wed, 23), at(wed, 23, 45), id = 1L) + val b = event(at(wed, 23, 58), at(wed, 23, 59), id = 2L) + val blocks = layoutDay(listOf(a, b), wed, zone) + assertThat(blocks.map { it.lane }).containsExactly(0, 1) + assertThat(blocks.all { it.laneCount == 2 }).isTrue() + } + + @Test + fun `a short entry clear of the next one keeps a single lane`() { + val a = event(at(wed, 9), at(wed, 9, 10), id = 1L) + val b = event(at(wed, 9, 30), at(wed, 10), id = 2L) + val blocks = layoutDay(listOf(a, b), wed, zone) + assertThat(blocks.all { it.lane == 0 && it.laneCount == 1 }).isTrue() + } + + @Test + fun `a block knows whether its event carries on past midnight`() { + val overnight = event(at(wed, 23), at(wed.plusDays(1), 1)) + assertThat(layoutDay(listOf(overnight), wed, zone).single().continuesAfter).isTrue() + assertThat(layoutDay(listOf(overnight), wed.plusDays(1), zone).single().continuesAfter).isFalse() + val toMidnight = event(at(wed, 23), at(wed.plusDays(1), 0)) + assertThat(layoutDay(listOf(toMidnight), wed, zone).single().continuesAfter).isFalse() + } + @Test fun `event spanning midnight is clipped to the day`() { // Starts the previous evening, ends 02:00 on wed. @@ -126,9 +164,9 @@ class WeekLayoutTest { } @Test - fun `all-day events are excluded from the timed layout`() { + fun `all-day events are kept out of the timed layout`() { val ev = event(at(wed, 0), at(wed.plusDays(1), 0), allDay = true) - assertThat(layoutDay(listOf(ev), wed, zone)).isEmpty() + assertThat(listOf(ev).splitWholeDays(zone)).isEqualTo(listOf(ev) to emptyList()) } @Test @@ -300,4 +338,30 @@ class WeekLayoutTest { val second = event(Instant.parse("2026-10-25T01:10:00Z"), Instant.parse("2026-10-25T01:40:00Z")) assertThat(layoutDay(listOf(second), day, brussels).single().endMin).isEqualTo(2 * 60 + 40) } + + @Test + fun `a timed event from midnight to midnight stays out of the time grid`() { + val ev = testEvent(start = at(wed, 0), end = at(wed.plus(1, DateTimeUnit.DAY), 0)) + + assertThat(listOf(ev).splitWholeDays(zone)).isEqualTo(listOf(ev) to emptyList()) + } + + @Test + fun `a bar held over another day makes room there`() { + val held = event(at(mon, 0), at(mon.plusDays(1), 0), allDay = true, id = 1, title = "A") + val there = event(at(wed, 0), at(wed.plusDays(1), 0), allDay = true, id = 2, title = "B") + val state = WeekUiState.Success( + weekStart = mon, today = mon, days = weekDays, + allDaySpans = layoutAllDay(listOf(held, there), weekDays, zone), timedByDay = emptyMap(), + ) + assertThat(state.allDaySpans.map { it.lane }).containsExactly(0, 0) + + val preview = state.withStripPreview(StripDrag(held, dayShift = 2), zone).allDaySpans + val moved = preview.single { it.event.instanceId == held.instanceId } + + assertThat(moved.event).isEqualTo(held) + assertThat(moved.startCol).isEqualTo(2) + assertThat(preview.map { it.lane }).containsExactly(0, 1) + assertThat(state.withStripPreview(StripDrag(held, dayShift = 2, overGrid = true), zone)).isSameInstanceAs(state) + } } 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..b88784c --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownTest.kt @@ -0,0 +1,111 @@ +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 de.jeanlucmakiola.calendula.domain.EventStatus +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, + status: EventStatus = EventStatus.Confirmed, + ) = EventInstance( + instanceId = id, eventId = id, calendarId = 1, title = "e$id", + start = start, end = end, isAllDay = allDay, color = 0, location = null, response = response, + status = status, + ) + + @Test + fun `the last half hour before the day mark still reads 23 h`() { + assertThat(countdownLabel(event(now + 23.hours + 40.minutes), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.HOURS, 23)) + } + + @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, zone)?.eventId).isEqualTo(4) + // A running event ending before the next one starts comes first. + assertThat(pickNextEvent(listOf(later, running), now, zone)?.eventId).isEqualTo(5) + } + + @Test + fun `next-event mode skips a timed event filling whole days`() { + val wholeDay = event(Instant.parse("2026-10-22T00:00:00Z"), Instant.parse("2026-10-23T00:00:00Z"), id = 1) + val later = event(now + 30.hours, id = 2) + assertThat(pickNextEvent(listOf(wholeDay, later), now, zone)?.eventId).isEqualTo(2) + } + + @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 `a pinned series skips cancelled occurrences`() { + val cancelled = event(now + 7.days, id = 1, status = EventStatus.Cancelled) + val next = event(now + 14.days, id = 2) + assertThat(pickPinnedOccurrence(listOf(cancelled, next), now, zone)).isEqualTo(next) + } + + @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/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 77fc57c..771d9bd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -295,7 +295,7 @@ Right before writing, the event is re-read and snapshots compared: a mismatch parks the save in an overwrite/discard dialog; a vanished event informs and closes. Overwrite still writes only dirty fields, so external changes to untouched fields survive either way. Fields the form cannot -write (attendees, status, reminder methods) are excluded so sync noise +write (your own response, reminder methods) are excluded so sync noise can't fake a conflict. A **dropped** event gets no conflict dialog, deliberately. Its blast radius is @@ -333,7 +333,7 @@ sequenceDiagram Src-->>Sc: Instances ⋈ Reminders Sc->>P: planReminders / scheduleReminders(watermark, now) P-->>Sc: due + next alarm - Sc->>N: post(alert) — tag = reminder key + Sc->>N: post(alert) — tag = occurrence, so a later reminder replaces it Sc->>A: scheduleScan(next) ``` diff --git a/fastlane/metadata/android/ar/changelogs/2240099.txt b/fastlane/metadata/android/ar/changelogs/2240099.txt new file mode 100644 index 0000000..ee1db73 --- /dev/null +++ b/fastlane/metadata/android/ar/changelogs/2240099.txt @@ -0,0 +1,13 @@ +الجديد +• سبع أدوات جديدة للشاشة الرئيسية: إضافة سريعة، التاريخ، شريط الأسبوع، القادم، اليوم، العدّ التنازلي، والشهر مع اليوم. +• لكل أداة إعداداتها الخاصة: الخلفية والمظهر والتقاويم. +• مظهر جديد لأداتي جدول الأعمال والشهر. +• اسحب أحداث اليوم الكامل إلى يوم آخر أو إلى الشبكة. +• تمييز الأحداث الملغاة والمبدئية. +• خيار لإخفاء أوقات البدء في عرض الشهر. + +الإصلاحات +• الأحداث القصيرة في آخر الليل تظهر كاملة. + +التغييرات +• إشعار واحد لكل حدث حتى مع عدة تذكيرات. diff --git a/fastlane/metadata/android/be/changelogs/2240099.txt b/fastlane/metadata/android/be/changelogs/2240099.txt new file mode 100644 index 0000000..0433d95 --- /dev/null +++ b/fastlane/metadata/android/be/changelogs/2240099.txt @@ -0,0 +1,13 @@ +Новае +• Сем новых віджэтаў, у тым ліку хуткае даданне, паласа тыдня, адваротны адлік і месяц з днём. +• У кожнага віджэта свае налады: фон, тэма і календары. +• Новы выгляд віджэтаў спісу падзей і месяца. +• Перацягвайце падзеі на ўвесь дзень на іншы дзень або ў сетку. +• Адмененыя і папярэднія падзеі пазначаюцца. +• Можна схаваць час пачатку ў выглядзе месяца. + +Выпраўлена +• Кароткія падзеі позна ўвечары бачныя цалкам. + +Зменена +• Адно апавяшчэнне на падзею. diff --git a/fastlane/metadata/android/cs-CZ/changelogs/2240099.txt b/fastlane/metadata/android/cs-CZ/changelogs/2240099.txt new file mode 100644 index 0000000..1f66025 --- /dev/null +++ b/fastlane/metadata/android/cs-CZ/changelogs/2240099.txt @@ -0,0 +1,13 @@ +Novinky +• Sedm nových widgetů, mimo jiné rychlé přidání, pruh týdne, odpočet a měsíc se dnem. +• Každý widget má vlastní nastavení: pozadí, motiv a kalendáře. +• Nový vzhled widgetů agendy a měsíce. +• Celodenní události lze přetáhnout na jiný den nebo do mřížky. +• Zrušené a předběžné události jsou označené. +• Možnost skrýt časy začátku v zobrazení měsíce. + +Opraveno +• Krátké události pozdě večer jsou vidět celé. + +Změněno +• Jedno oznámení na událost. diff --git a/fastlane/metadata/android/de-DE/changelogs/2240099.txt b/fastlane/metadata/android/de-DE/changelogs/2240099.txt new file mode 100644 index 0000000..6903388 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/2240099.txt @@ -0,0 +1,13 @@ +Neu +• Sieben neue Widgets, u. a. Schnell hinzufügen, Wochenleiste, Countdown und Monat mit Tag. +• Jedes Widget hat eigene Einstellungen: Hintergrund, Design und Kalender. +• Neuer Look für Terminliste- und Monats-Widget. +• Ganztägige Termine auf einen anderen Tag oder ins Raster ziehen. +• Abgesagte und vorläufige Termine werden markiert. +• Startzeiten in der Monatsansicht ausblendbar. + +Behoben +• Kurze Termine spät am Abend voll sichtbar. + +Geändert +• Eine Benachrichtigung pro Termin. diff --git a/fastlane/metadata/android/en-US/changelogs/2240099.txt b/fastlane/metadata/android/en-US/changelogs/2240099.txt new file mode 100644 index 0000000..520923d --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/2240099.txt @@ -0,0 +1,13 @@ +New +• Seven new widgets, including quick add, week strip, countdown and month with day. +• Each widget has its own settings: background, theme and calendars. +• New look for the agenda and month widgets. +• Drag all-day events to another day or into the grid. +• Cancelled and tentative events are marked. +• Option to hide start times in the month view. + +Fixed +• Short events late at night are fully visible. + +Changed +• One notification per event. diff --git a/fastlane/metadata/android/es-ES/changelogs/2240099.txt b/fastlane/metadata/android/es-ES/changelogs/2240099.txt new file mode 100644 index 0000000..4d176cb --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/2240099.txt @@ -0,0 +1,13 @@ +Novedades +• Siete widgets nuevos, como añadir rápido, franja semanal, cuenta atrás y mes con día. +• Ajustes por widget: fondo, tema y calendarios. +• Nuevo aspecto de los widgets de agenda y mes. +• Arrastra eventos de día completo a otro día o a la cuadrícula. +• Eventos cancelados y provisionales marcados. +• Horas de inicio ocultables en la vista de mes. + +Correcciones +• Los eventos cortos a última hora se ven completos. + +Cambios +• Una sola notificación por evento. diff --git a/fastlane/metadata/android/fr-FR/changelogs/2240099.txt b/fastlane/metadata/android/fr-FR/changelogs/2240099.txt new file mode 100644 index 0000000..2265291 --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/2240099.txt @@ -0,0 +1,13 @@ +Nouveautés +• Sept nouveaux widgets, dont ajout rapide, semaine, compte à rebours et mois avec jour. +• Réglages par widget : fond, thème, agendas. +• Nouveau look des widgets agenda et mois. +• Glissez les événements d’une journée vers un autre jour ou dans la grille. +• Événements annulés et provisoires signalés. +• Heures de début masquables dans la vue mois. + +Corrections +• Les événements courts en fin de soirée sont entièrement visibles. + +Modifications +• Une seule notification par événement. diff --git a/fastlane/metadata/android/hu-HU/changelogs/2240099.txt b/fastlane/metadata/android/hu-HU/changelogs/2240099.txt new file mode 100644 index 0000000..6e24e6e --- /dev/null +++ b/fastlane/metadata/android/hu-HU/changelogs/2240099.txt @@ -0,0 +1,13 @@ +Újdonságok +• Hét új widget, köztük gyors hozzáadás, heti sáv, visszaszámláló és hónap nappal. +• Minden widgetnek saját beállításai vannak: háttér, téma és naptárak. +• Új megjelenés a napirend és a havi widgetnek. +• Az egész napos események áthúzhatók másik napra vagy a rácsba. +• A lemondott és a feltételes események jelölve vannak. +• A kezdési időpontok elrejthetők a havi nézetben. + +Javítva +• A késő esti rövid események teljesen láthatók. + +Változások +• Eseményenként egy értesítés. diff --git a/fastlane/metadata/android/it-IT/changelogs/2240099.txt b/fastlane/metadata/android/it-IT/changelogs/2240099.txt new file mode 100644 index 0000000..ebe2afd --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/2240099.txt @@ -0,0 +1,13 @@ +Novità +• Sette nuovi widget, tra cui aggiunta rapida, settimana, conto alla rovescia e mese con giorno. +• Impostazioni per widget: sfondo, tema, calendari. +• Nuovo aspetto dei widget agenda e mese. +• Trascina gli eventi di tutto il giorno su un altro giorno o nella griglia. +• Eventi annullati e provvisori contrassegnati. +• Orari di inizio nascondibili nella vista mese. + +Correzioni +• Gli eventi brevi a tarda sera sono interamente visibili. + +Modifiche +• Una sola notifica per evento. diff --git a/fastlane/metadata/android/ja-JP/changelogs/2240099.txt b/fastlane/metadata/android/ja-JP/changelogs/2240099.txt new file mode 100644 index 0000000..275938c --- /dev/null +++ b/fastlane/metadata/android/ja-JP/changelogs/2240099.txt @@ -0,0 +1,13 @@ +新機能 +• 新しいウィジェット7種:クイック追加、日付、週ストリップ、今後の予定、今日、カウントダウン、月と日。 +• ウィジェットごとに背景・テーマ・カレンダーを設定できます。 +• 予定リストと月のウィジェットのデザインを一新。 +• 終日の予定を別の日やタイムグリッドへドラッグできます。 +• キャンセル済み・仮の予定に印が付きます。 +• 月表示で開始時刻を非表示にできます。 + +修正 +• 深夜の短い予定が全体まで表示されます。 + +変更 +• リマインダーが複数あっても通知は予定ごとに1件になりました。 diff --git a/fastlane/metadata/android/nl-NL/changelogs/2240099.txt b/fastlane/metadata/android/nl-NL/changelogs/2240099.txt new file mode 100644 index 0000000..0d4a8d3 --- /dev/null +++ b/fastlane/metadata/android/nl-NL/changelogs/2240099.txt @@ -0,0 +1,13 @@ +Nieuw +• Zeven nieuwe widgets, waaronder snel toevoegen, weekstrook, aftellen en maand met dag. +• Elke widget heeft eigen instellingen: achtergrond, thema en agenda’s. +• Nieuw uiterlijk voor de agenda- en maandwidget. +• Sleep hele-dag-afspraken naar een andere dag of in het raster. +• Geannuleerde en voorlopige afspraken worden gemarkeerd. +• Begintijden in de maandweergave te verbergen. + +Opgelost +• Korte afspraken laat op de avond volledig zichtbaar. + +Gewijzigd +• Eén melding per afspraak. diff --git a/fastlane/metadata/android/pl-PL/changelogs/2240099.txt b/fastlane/metadata/android/pl-PL/changelogs/2240099.txt new file mode 100644 index 0000000..f028dcd --- /dev/null +++ b/fastlane/metadata/android/pl-PL/changelogs/2240099.txt @@ -0,0 +1,13 @@ +Nowości +• Siedem nowych widżetów, m.in. szybkie dodawanie, pasek tygodnia, odliczanie i miesiąc z dniem. +• Każdy widżet ma własne ustawienia: tło, motyw i kalendarze. +• Nowy wygląd widżetów agendy i miesiąca. +• Przeciągaj wydarzenia całodniowe na inny dzień lub do siatki. +• Wydarzenia odwołane i wstępne są oznaczone. +• Opcja ukrycia godzin rozpoczęcia w widoku miesiąca. + +Poprawki +• Krótkie wydarzenia późnym wieczorem są w pełni widoczne. + +Zmiany +• Jedno powiadomienie na wydarzenie. diff --git a/fastlane/metadata/android/pt-BR/changelogs/2240099.txt b/fastlane/metadata/android/pt-BR/changelogs/2240099.txt new file mode 100644 index 0000000..e895ff2 --- /dev/null +++ b/fastlane/metadata/android/pt-BR/changelogs/2240099.txt @@ -0,0 +1,13 @@ +Novidades +• Sete novos widgets, como adição rápida, faixa da semana, contagem regressiva e mês com dia. +• Configurações por widget: fundo, tema e agendas. +• Novo visual dos widgets de agenda e mês. +• Arraste eventos de dia inteiro para outro dia ou para a grade. +• Eventos cancelados e provisórios marcados. +• Horários de início ocultáveis na visualização de mês. + +Correções +• Eventos curtos tarde da noite aparecem por inteiro. + +Alterações +• Uma notificação por evento. diff --git a/fastlane/metadata/android/pt-PT/changelogs/2240099.txt b/fastlane/metadata/android/pt-PT/changelogs/2240099.txt new file mode 100644 index 0000000..27c74df --- /dev/null +++ b/fastlane/metadata/android/pt-PT/changelogs/2240099.txt @@ -0,0 +1,13 @@ +Novidades +• Sete novos widgets, como adição rápida, faixa da semana, contagem decrescente e mês com dia. +• Definições por widget: fundo, tema e calendários. +• Novo aspeto dos widgets de agenda e mês. +• Arraste eventos de dia inteiro para outro dia ou para a grelha. +• Eventos cancelados e provisórios assinalados. +• Horas de início ocultáveis na vista de mês. + +Correções +• Os eventos curtos ao fim da noite ficam totalmente visíveis. + +Alterações +• Uma notificação por evento. diff --git a/fastlane/metadata/android/ru-RU/changelogs/2240099.txt b/fastlane/metadata/android/ru-RU/changelogs/2240099.txt new file mode 100644 index 0000000..681f60a --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/2240099.txt @@ -0,0 +1,13 @@ +Новое +• Семь новых виджетов, в том числе быстрое добавление, полоса недели, обратный отсчёт и месяц с днём. +• У каждого виджета свои настройки: фон, тема и календари. +• Новый вид виджетов повестки и месяца. +• Перетаскивайте события на весь день на другой день или в сетку. +• Отменённые и предварительные события отмечаются. +• Можно скрыть время начала в виде месяца. + +Исправлено +• Короткие события поздно вечером видны полностью. + +Изменено +• Одно уведомление на событие. diff --git a/fastlane/metadata/android/sk/changelogs/2240099.txt b/fastlane/metadata/android/sk/changelogs/2240099.txt new file mode 100644 index 0000000..caff591 --- /dev/null +++ b/fastlane/metadata/android/sk/changelogs/2240099.txt @@ -0,0 +1,13 @@ +Novinky +• Sedem nových widgetov, napríklad rýchle pridanie, pás týždňa, odpočítavanie a mesiac s dňom. +• Každý widget má vlastné nastavenia: pozadie, motív a kalendáre. +• Nový vzhľad widgetov agendy a mesiaca. +• Celodenné udalosti môžete presunúť na iný deň alebo do mriežky. +• Zrušené a predbežné udalosti sú označené. +• Možnosť skryť časy začiatku v zobrazení mesiaca. + +Opravené +• Krátke udalosti neskoro večer sú viditeľné celé. + +Zmenené +• Jedno upozornenie na udalosť. diff --git a/fastlane/metadata/android/zh-CN/changelogs/2240099.txt b/fastlane/metadata/android/zh-CN/changelogs/2240099.txt new file mode 100644 index 0000000..e76b734 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/changelogs/2240099.txt @@ -0,0 +1,13 @@ +新功能 +• 七个新的桌面小部件:快速添加、日期、周条、即将到来、今天、倒计时,以及月份与日程。 +• 每个小部件都有独立设置:背景、主题和日历。 +• 日程列表和月份小部件换上新外观。 +• 可将全天事件拖到其他日期或时间网格中。 +• 已取消和暂定的事件会被标出。 +• 可在月视图中隐藏开始时间。 + +修复 +• 深夜的短事件现在完整显示。 + +变更 +• 即使有多个提醒,每个事件也只保留一条通知。 diff --git a/fastlane/metadata/android/zh-TW/changelogs/2240099.txt b/fastlane/metadata/android/zh-TW/changelogs/2240099.txt new file mode 100644 index 0000000..5143383 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/changelogs/2240099.txt @@ -0,0 +1,13 @@ +新功能 +• 七個新的桌面小工具:快速新增、日期、週列、即將到來、今天、倒數,以及月份與行程。 +• 每個小工具都有獨立設定:背景、主題和行事曆。 +• 行程列表和月份小工具換上新外觀。 +• 可將全天活動拖到其他日期或時間格中。 +• 已取消和暫定的活動會被標示。 +• 可在月檢視中隱藏開始時間。 + +修正 +• 深夜的短活動現在會完整顯示。 + +變更 +• 即使有多個提醒,每個活動也只保留一則通知。 diff --git a/fastlane/metadata/android/zh-TW/full_description.txt b/fastlane/metadata/android/zh-TW/full_description.txt new file mode 100644 index 0000000..df5f137 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/full_description.txt @@ -0,0 +1,11 @@ +Calendula 是一款開源的 Android 行事曆應用程式。它直接建立在系統行事曆提供者之上,因此同步到裝置上的所有內容(透過 DAVx5 同步的 Nextcloud、Google、本機行事曆、WebCal 訂閱)都會顯示在 Calendula 中,你所做的變更也會以同樣的方式同步回去。 + +提供月、週、日和行程列表檢視。你可以建立、編輯和刪除行程,包括週期性行程,重複規則可以從預設中選擇,也可以自訂。修改週期性行程時,你可以選擇只修改這一次、修改這一次及之後的所有行程,或修改整個系列。 + +Calendula 會以通知傳送行程提醒。輕觸通知即可開啟對應的行程。 + +啟用後,Calendula 也會顯示聯絡人的生日、紀念日和其他日期。聯絡人只在裝置上讀取,不會上傳,也不會被修改。Calendula 會把這些日期複製到由你管理的本機行事曆中。 + +整體設計採用 Material 3 Expressive,支援動態配色以及富有表現力的動畫和形狀。 + +Calendula 沒有遙測、沒有分析,也不存取網路。你的資料不會離開裝置。 diff --git a/fastlane/metadata/android/zh-TW/short_description.txt b/fastlane/metadata/android/zh-TW/short_description.txt new file mode 100644 index 0000000..0420cdc --- /dev/null +++ b/fastlane/metadata/android/zh-TW/short_description.txt @@ -0,0 +1 @@ +一款採用 Material 3 Expressive 設計、不連網的 Android 行事曆。 diff --git a/fastlane/metadata/android/zh-TW/title.txt b/fastlane/metadata/android/zh-TW/title.txt new file mode 100644 index 0000000..13c9555 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/title.txt @@ -0,0 +1 @@ +Calendula:行事曆 diff --git a/fastlane/store-locales.txt b/fastlane/store-locales.txt index 0c08481..9c3be13 100644 --- a/fastlane/store-locales.txt +++ b/fastlane/store-locales.txt @@ -19,3 +19,4 @@ pt-rBR pt-BR ru ru-RU sk sk zh-rCN zh-CN +zh-rTW zh-TW diff --git a/floret-kit b/floret-kit index 9f90027..1ff6702 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 9f90027dcd9f1f7a0511bea462ecdf297387b912 +Subproject commit 1ff6702fe6ff1ab695157e6559ec3761ba9f01b9