Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49cb11fe09 | ||
|
|
32668623a6 | ||
|
|
6e673a1cec | ||
|
|
11c1b71c85 | ||
|
|
28c4e68de4 | ||
|
|
7a43ee0595 | ||
|
|
3ed12f1fee | ||
|
|
16fe2687e8 | ||
|
|
f55cfffa3a | ||
|
|
bb8c91acc5 | ||
|
|
b627ed04b7 | ||
|
|
d8e3250af9 | ||
|
|
8bd2ac7a9a | ||
|
|
c1c529d4bd | ||
|
|
aca048e593 | ||
|
|
440f85d89f | ||
|
|
bf2e59a14e | ||
|
|
19f0dcae5d | ||
|
|
c3401ed61c | ||
|
|
e880500ed8 | ||
|
|
0121aa5b1d | ||
|
|
cf3c4e96cd | ||
|
|
0dd7e07aa0 | ||
|
|
2b2a31ecda | ||
|
|
0f75d2638b | ||
|
|
9fe1ebb0fb | ||
|
|
951ad9cd6d | ||
|
|
6cb2f62627 | ||
|
|
427e87c6e6 | ||
|
|
dce83005ea | ||
|
|
1ac486cc4b | ||
|
|
549ef1dfbf | ||
|
|
164b5fb25d |
+1
-44
@@ -8,23 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Six new home-screen widgets.** Quick add (1×1) opens a new event on today with the same defaults as the app's + button ([#435]). Date (1×1) shows today's weekday and day number on a rounded shape and opens today ([#376]). Week strip (4×1) shows this week's days with a dot in each event's colour and today filled ([#436]). Upcoming (2×2) shows today's date over your next events, as many as fit ([#437]). Today lists today's events; once they're over it says "Nothing left today" and shows tomorrow's first one or two ([#438]). Countdown counts down to your next timed event, or to an event or series you pick, like a holiday in 12 days ([#439]).
|
||||
- **A month and day widget.** A compact month grid sits beside the events of one day. Tap a day in the grid to see its events, and tap the list to open that day in the app ([#441]).
|
||||
- **Each widget has its own settings.** Placing a widget, or long-pressing it and choosing Edit, opens a settings screen with a live preview. Pick a solid, tinted or transparent background, a light or dark theme or the app's, and which calendars it shows. Widgets that list events also have their time range, text size and how past events look. Settings → Widgets lists the widgets on your home screen so you can change them from there. These options used to be shared by all widgets; widgets you already placed keep what you had set ([#292]).
|
||||
|
||||
### Changed
|
||||
- **The agenda and month widgets have a new look.** The agenda puts each day in its own card, with a round date pill beside the first event and today's pill filled. The month widget has rounded day cells, weekday pills, shaded days outside the month and a + for a new event. The agenda's refresh button is gone, since widgets now update on their own ([#71], [#90]).
|
||||
- **Tapping a day in the month widget opens that day,** wherever in its cell you tap ([#386]).
|
||||
- **The widget picker shows what each widget looks like.** On Android 15 and later the previews are the real widgets with sample events, in your language and colours; on older versions they are redrawn to match.
|
||||
|
||||
### Fixed
|
||||
- **Widgets show your changes straight away.** After you added, edited or deleted an event, a widget could keep showing the old events until something else redrew it ([#420], [#229]).
|
||||
- **The month widget handles busy months.** It could show "Can't show content", or only one or two events per day. Each day now shows as many events as its height fits, and the rest fold into +N ([#372]).
|
||||
|
||||
## [2.23.0] — 2026-10-21
|
||||
|
||||
### Added
|
||||
- **Long press an event to select it.** In the day, 3-day and week views, a long press lifts the event and selects it. Keep your finger down and move to drag it straight away. A selected event has handles at its top and bottom: drag them to change when it starts or ends, in 15-minute steps and across midnight if you want. The bar at the top edits, duplicates, shares or deletes it, and tapping elsewhere or going back deselects it. The bar shows the new times while you move or resize. Read-only events can be selected but not moved or resized ([#141]).
|
||||
- **Long press an event to select it.** In the day, 3-day and week views, a long press lifts the event and selects it. Keep your finger down and move to drag it straight away. A selected event has handles at its top and bottom: drag them to change when it starts or ends, in 15-minute steps and across midnight if you want. The bar at the top edits, duplicates, shares or deletes it, and tapping elsewhere or going back deselects it. Read-only events can be selected but not moved or resized ([#141]).
|
||||
- **Long press an empty slot to draft an event.** In the day, 3-day and week views, a long press on free time drops a block of your default event length there. Move it while still holding, or let go and drag it or its handles into place, then tap the tick to open a new event at that time. The ✕, a tap elsewhere or going back throws the draft away ([#153]).
|
||||
- **Tap a phone number in an event to call it.** Phone numbers in an event's title, location and description are now links. Tapping one opens your phone app with the number filled in, ready to call. A conference dial-in like "+1 646 558 8656,,850123#" brings its meeting ID along. Meeting IDs and PINs on their own, dates, times and amounts stay plain text. Long-pressing a field copies it, even when you press on a link ([#339]).
|
||||
- **Formatted event descriptions read properly.** Descriptions from Outlook, Exchange and Google Calendar are often stored as HTML, which used to show up as a wall of tags. The event view now shows them as formatted text: bold, italics, lists and links work, table rows sit on one line each, and the fonts and colours the sender picked are left out so the text matches the app. Search matches the text you see. The edit form still shows the original, so saving an event never changes its formatting ([#271]).
|
||||
@@ -32,23 +16,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Create an event from shared or selected text.** Share text to Calendula, or select text in another app and pick Create event from the selection menu, and the new-event form opens with it filled in. A shared subject becomes the title, otherwise the first line does, and the rest goes into the description. The event starts at the next full hour with your default length; Calendula doesn't try to read a date out of the text. If Calendula was opened just for this, saving or closing the form takes you back to the app you came from ([#306]).
|
||||
- **Tapping an event can open the edit form.** Settings → Views → Events → Tapping an event opens can now be set to Edit form instead of Details. Taps in the views and in search then go straight to the editor for events you can edit; read-only events, reminder notifications and widgets still open the details. The editor has Duplicate, Share and Delete in its menu, and going back with unsaved changes asks whether to save, discard or keep editing ([#178]).
|
||||
- **A black theme for AMOLED screens.** Settings → Appearance → Theme now has Black next to System, Light and Dark. It's the dark theme with pure black backgrounds, which saves battery on AMOLED screens and makes event colours stand out more. Day cells and the hour grid stay a shade above black so the grid lines still show, and sheets and menus still stand out from the page. It works with Material You colours too. The home-screen widgets aren't affected ([#181]).
|
||||
- **Donate through Liberapay.** Settings → Support development now offers Liberapay for recurring donations next to Ko-fi for one-off tips.
|
||||
- **More haptic feedback.** Swiping between days in the split month pane ticks, and dropping a moved or resized event gives a short buzz ([#116]).
|
||||
|
||||
### Changed
|
||||
- **The editor says Create for a new event.** New events, duplicates, shared text, imported .ics files and long-press drafts show Create instead of Save, so a duplicate no longer looks like you're editing the original ([#141]).
|
||||
|
||||
### Fixed
|
||||
- **Event colours follow the theme you picked in Calendula.** With the app set to Dark on a phone in light mode, or the other way round, events took their colours from the phone's setting, so they could come out too pale or too dark. They now match the app's theme. Starting the app with a theme set in Calendula also no longer flashes the phone's light or dark background first ([#181]).
|
||||
- **Events on the day the clocks change show at their real time.** In the day, 3-day and week views, events later on the day of a daylight saving change were drawn an hour off, so a 14:45 event showed at 15:45. They now sit at their own time. When the clocks go back, 02:00 to 03:00 happens twice: an event in that hour now stays in the right one when you move, resize or edit it, and the editor and event details add the zone, like CEST or CET, so you can tell the two apart ([#387]).
|
||||
- **The agenda always starts today.** Coming from the month or day view, the agenda used to start at the day you had open there, so "Next 30 days" could show the 1st to the 30th of the month. It now opens on today with your chosen range. Backing out of a day you opened from the agenda still returns you to where you were ([#367]).
|
||||
- **Multi-day events dim on the days already behind you.** With past events dimmed, an event running over several days stayed at full strength on its earlier days until it ended. Those days now dim in the agenda, month, week and day views ([#367]).
|
||||
- **Moving a recurring event to another calendar keeps the occurrences you changed.** Deleted occurrences used to come back after the move, and edited ones showed up twice. They now stay deleted or edited, and keep their colour.
|
||||
- **Sharing an .ics file to Calendula opens the import,** even when the email app adds a subject line as text.
|
||||
- **Rotating the phone no longer throws away an open import, duplicate or picker.**
|
||||
- **The repeat-until date picker opens on today.** Near midnight it used to open on yesterday or tomorrow.
|
||||
- **Tap ripples on week numbers, split-view day cells and the split-view handle stay inside their rounded shape** ([#413]).
|
||||
- **The export calendar picker previews the back gesture** like the other full-screen pickers.
|
||||
|
||||
## [2.22.5] — 2026-10-07
|
||||
|
||||
@@ -1901,19 +1874,3 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#181]: https://codeberg.org/jlmakiola/calendula/issues/181
|
||||
[#306]: https://codeberg.org/jlmakiola/calendula/issues/306
|
||||
[#178]: https://codeberg.org/jlmakiola/calendula/issues/178
|
||||
[#116]: https://codeberg.org/jlmakiola/calendula/issues/116
|
||||
[#413]: https://codeberg.org/jlmakiola/calendula/issues/413
|
||||
[#71]: https://codeberg.org/jlmakiola/calendula/issues/71
|
||||
[#90]: https://codeberg.org/jlmakiola/calendula/issues/90
|
||||
[#229]: https://codeberg.org/jlmakiola/calendula/issues/229
|
||||
[#292]: https://codeberg.org/jlmakiola/calendula/issues/292
|
||||
[#372]: https://codeberg.org/jlmakiola/calendula/issues/372
|
||||
[#376]: https://codeberg.org/jlmakiola/calendula/issues/376
|
||||
[#386]: https://codeberg.org/jlmakiola/calendula/issues/386
|
||||
[#420]: https://codeberg.org/jlmakiola/calendula/issues/420
|
||||
[#435]: https://codeberg.org/jlmakiola/calendula/issues/435
|
||||
[#436]: https://codeberg.org/jlmakiola/calendula/issues/436
|
||||
[#437]: https://codeberg.org/jlmakiola/calendula/issues/437
|
||||
[#438]: https://codeberg.org/jlmakiola/calendula/issues/438
|
||||
[#439]: https://codeberg.org/jlmakiola/calendula/issues/439
|
||||
[#441]: https://codeberg.org/jlmakiola/calendula/issues/441
|
||||
|
||||
@@ -23,8 +23,6 @@ It works on top of the system calendar and has no network access.</p>
|
||||
<a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula"><img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Get it on Obtainium" height="56"></a>
|
||||
|
||||
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
|
||||
|
||||
<a href="https://liberapay.com/Jean-Luc_Makiola/donate"><img src="https://liberapay.com/assets/widgets/donate.svg" alt="Donate using Liberapay" height="56"></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
@@ -205,13 +203,6 @@ anywhere. Your events stay in Android's own calendar store.
|
||||
[Privacy policy](https://jeanlucmakiola.de/calendula/privacy), written in
|
||||
[`docs/PRIVACY.md`](docs/PRIVACY.md), which is what that page renders.
|
||||
|
||||
## Support
|
||||
|
||||
Calendula is free and has no ads. If you'd like to support its development,
|
||||
you can tip once on [Ko-fi](https://ko-fi.com/jeanlucmakiola) or donate
|
||||
regularly on [Liberapay](https://liberapay.com/Jean-Luc_Makiola/donate). Both
|
||||
are linked in the app under *Settings → Support development*.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE), Jean-Luc Makiola, 2026
|
||||
|
||||
@@ -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 = 2240001
|
||||
versionName = "2.24.0-beta.1"
|
||||
versionCode = 2230004
|
||||
versionName = "2.23.0-beta.4"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -181,7 +181,6 @@ 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)
|
||||
|
||||
-140
@@ -1,140 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -152,7 +152,7 @@
|
||||
</intent-filter>
|
||||
|
||||
<!-- Text shared or selected in another app opens a prefilled new
|
||||
event (MainActivity.sharedTextRequestOrNull, issue #306). -->
|
||||
event (MainActivity.sharedTextExtrasOrNull, issue #306). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
@@ -318,16 +318,6 @@
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Home-screen widgets (Glance). Exported: the launcher/host binds them. -->
|
||||
<activity
|
||||
android:name=".widget.config.WidgetConfigActivity"
|
||||
android:exported="true"
|
||||
android:excludeFromRecents="true"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/Theme.Calendula.WidgetConfig">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<receiver
|
||||
android:name=".widget.agenda.AgendaWidgetReceiver"
|
||||
android:label="@string/widget_agenda_label"
|
||||
@@ -352,103 +342,13 @@
|
||||
android:resource="@xml/appwidget_info_month" />
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".widget.quickadd.QuickAddWidgetReceiver"
|
||||
android:label="@string/widget_quick_add_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_quick_add" />
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".widget.date.DateWidgetReceiver"
|
||||
android:label="@string/widget_date_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_date" />
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".widget.week.WeekStripWidgetReceiver"
|
||||
android:label="@string/widget_week_strip_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_week_strip" />
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".widget.upcoming.UpcomingWidgetReceiver"
|
||||
android:label="@string/widget_upcoming_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_upcoming" />
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".widget.today.TodayWidgetReceiver"
|
||||
android:label="@string/widget_today_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_today" />
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".widget.countdown.CountdownWidgetReceiver"
|
||||
android:label="@string/widget_countdown_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_countdown" />
|
||||
</receiver>
|
||||
|
||||
<!-- A countdown widget's redraw for when its label changes (#439). -->
|
||||
<receiver
|
||||
android:name=".widget.countdown.CountdownTickReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".widget.split.MonthDayWidgetReceiver"
|
||||
android:label="@string/widget_month_day_label"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/appwidget_info_month_day" />
|
||||
</receiver>
|
||||
|
||||
<!-- Keeps both widgets fresh: the calendar provider broadcasts
|
||||
PROVIDER_CHANGED on any data change (external sync; the app's own
|
||||
writes redraw directly). The day boundary arrives as the app's own
|
||||
ROLLOVER alarm (#228), by explicit PendingIntent, so it needs no
|
||||
filter here; DATE_CHANGED is a free extra only, since Android 8+
|
||||
withholds it from manifest receivers. TIME_SET / TIMEZONE_CHANGED
|
||||
move the boundary, boot / package-replace wipe the alarm, and
|
||||
LOCALE_CHANGED redraws the labels in the new language.
|
||||
PROVIDER_CHANGED on any data change (our writes and external sync).
|
||||
The day boundary arrives as the app's own ROLLOVER alarm (#228), by
|
||||
explicit PendingIntent, so it needs no filter here; DATE_CHANGED is
|
||||
a free extra only, since Android 8+ withholds it from manifest
|
||||
receivers. The four below re-arm that alarm: TIME_SET /
|
||||
TIMEZONE_CHANGED move the boundary, boot / package-replace wipe it.
|
||||
Exported: the system broadcasts arrive from outside the app. -->
|
||||
<receiver
|
||||
android:name=".widget.WidgetUpdateReceiver"
|
||||
@@ -465,7 +365,6 @@
|
||||
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
<action android:name="android.intent.action.LOCALE_CHANGED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
|
||||
@@ -10,9 +10,7 @@ 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.publishCalendulaWidgetPreviews
|
||||
import de.jeanlucmakiola.calendula.widget.syncWidgetRollover
|
||||
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
|
||||
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -45,8 +43,6 @@ class CalendulaApp : Application() {
|
||||
chooseIssueUrl = getString(R.string.report_issue_choose_url),
|
||||
),
|
||||
)
|
||||
// Before anything that writes, so the widgets see every change (#420).
|
||||
widgetEntryPoint().widgetRefreshCoordinator().start()
|
||||
reconcileAutoBackup()
|
||||
reconcileSpecialDates()
|
||||
reconcileCalendarVisibility()
|
||||
@@ -59,12 +55,10 @@ 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 {
|
||||
syncWidgetRollover(this@CalendulaApp)
|
||||
publishCalendulaWidgetPreviews(this@CalendulaApp)
|
||||
WidgetRolloverScheduler.sync(this@CalendulaApp)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
@@ -62,8 +61,6 @@ import de.jeanlucmakiola.calendula.ui.theme.WindowThemeCache
|
||||
import de.jeanlucmakiola.calendula.ui.theme.windowBackgroundRes
|
||||
import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
|
||||
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
@@ -127,17 +124,10 @@ class MainActivity : AppCompatActivity() {
|
||||
cachedTheme?.windowBackgroundRes()?.let { window.setBackgroundDrawableResource(it) }
|
||||
systemBarsDark = (cachedTheme ?: ThemeMode.SYSTEM).isDark(systemDark)
|
||||
applyEdgeToEdge()
|
||||
val appearance = MutableStateFlow(
|
||||
Appearance(
|
||||
themeMode = cachedTheme ?: ThemeMode.SYSTEM,
|
||||
dynamicColor = WindowThemeCache.readDynamicColor(this),
|
||||
),
|
||||
)
|
||||
lifecycleScope.launch {
|
||||
combine(settingsPrefs.themeMode, settingsPrefs.dynamicColor, ::Appearance).collect { stored ->
|
||||
WindowThemeCache.write(this@MainActivity, stored.themeMode, stored.dynamicColor ?: true)
|
||||
window.setBackgroundDrawableResource(stored.themeMode.windowBackgroundRes() ?: R.color.window_background)
|
||||
appearance.value = stored
|
||||
settingsPrefs.themeMode.collect { mode ->
|
||||
WindowThemeCache.write(this@MainActivity, mode)
|
||||
window.setBackgroundDrawableResource(mode.windowBackgroundRes() ?: R.color.window_background)
|
||||
}
|
||||
}
|
||||
// Android hands the intent that started the task back to onCreate every
|
||||
@@ -153,7 +143,6 @@ class MainActivity : AppCompatActivity() {
|
||||
// is what carries one through a recreation — the create form only while
|
||||
// it's still open.
|
||||
val replaying = savedInstanceState != null || intent.isRelaunch()
|
||||
requests.replayed = savedInstanceState != null
|
||||
if (!replaying) {
|
||||
requests.detailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
||||
requests.editKey = intent.editEventKeyOrNull()
|
||||
@@ -172,11 +161,7 @@ class MainActivity : AppCompatActivity() {
|
||||
// and the Settings screen, so a theme change applies app-wide at once.
|
||||
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
||||
val settings by settingsViewModel.state.collectAsStateWithLifecycle()
|
||||
// Seeded from the cache, so the first frame doesn't draw the default
|
||||
// theme and palette before the stored choices arrive.
|
||||
val shownAppearance by appearance.collectAsStateWithLifecycle()
|
||||
val themeMode = shownAppearance.themeMode
|
||||
val darkTheme = themeMode.isDark(isSystemInDarkTheme())
|
||||
val darkTheme = settings.themeMode.isDark(isSystemInDarkTheme())
|
||||
// onCreate can only see the night resource qualifier, not the in-app
|
||||
// override — re-apply from the resolved theme so the bar icons follow
|
||||
// the app's light/dark choice.
|
||||
@@ -214,8 +199,8 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
CalendulaTheme(
|
||||
darkTheme = darkTheme,
|
||||
dynamicColor = shownAppearance.dynamicColor ?: settings.dynamicColor,
|
||||
pureBlack = themeMode.isPureBlack,
|
||||
dynamicColor = settings.dynamicColor,
|
||||
pureBlack = settings.themeMode.isPureBlack,
|
||||
typography = typography,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
@@ -288,7 +273,6 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
requests.replayed = false
|
||||
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requests.detailKey = it }
|
||||
intent.navRequestOrNull()?.let { requests.nav = it }
|
||||
intent.importUriOrNull()?.let { requests.importUri = it }
|
||||
@@ -297,13 +281,6 @@ class MainActivity : AppCompatActivity() {
|
||||
insertFormOpen = true
|
||||
}
|
||||
intent.editEventKeyOrNull()?.let { requests.editKey = it }
|
||||
// The user moved on into the app, so closing the create form no longer
|
||||
// hands control back to the app that started it.
|
||||
if (requests.detailKey != null || requests.nav != null || requests.importUri != null ||
|
||||
requests.editKey != null
|
||||
) {
|
||||
launchedForInsert = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -324,10 +301,9 @@ class MainActivity : AppCompatActivity() {
|
||||
private fun Intent.importUriOrNull(): Uri? {
|
||||
val uri = when (action) {
|
||||
Intent.ACTION_VIEW -> data
|
||||
// Shared text is a create (#306); a text/plain file is imported only
|
||||
// when it is calendar data, e.g. an .ics attachment labelled text/plain.
|
||||
Intent.ACTION_SEND -> sharedStreamOrNull()
|
||||
?.takeUnless { type == SHARED_TEXT_TYPE && !it.isCalendarFile() }
|
||||
// Plain text is a create (#306), never a file to import.
|
||||
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
|
||||
.takeUnless { type == SHARED_TEXT_TYPE }
|
||||
else -> null
|
||||
} ?: return null
|
||||
// The calendar "view time" Uri (a date tap) is also ACTION_VIEW/content;
|
||||
@@ -381,7 +357,7 @@ class MainActivity : AppCompatActivity() {
|
||||
*/
|
||||
private fun Intent.sharedTextRequestOrNull(): InsertRequest? {
|
||||
val shared = when (action) {
|
||||
Intent.ACTION_SEND -> if (type == SHARED_TEXT_TYPE && sharedStreamOrNull()?.isCalendarFile() != true) {
|
||||
Intent.ACTION_SEND -> if (type == SHARED_TEXT_TYPE) {
|
||||
sharedTextOrNull(
|
||||
subject = getCharSequenceExtra(Intent.EXTRA_SUBJECT)?.toString(),
|
||||
text = getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString(),
|
||||
@@ -402,23 +378,6 @@ class MainActivity : AppCompatActivity() {
|
||||
return InsertRequest(form, ImportSource.InsertOpenEnded)
|
||||
}
|
||||
|
||||
private fun Intent.sharedStreamOrNull(): Uri? =
|
||||
IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
|
||||
|
||||
/** Whether a shared file is iCalendar/vCalendar data, by its resolved type or its name. */
|
||||
private fun Uri.isCalendarFile(): Boolean {
|
||||
val resolvedType = runCatching { contentResolver.getType(this) }.getOrNull()
|
||||
if (resolvedType in CALENDAR_TYPES) return true
|
||||
val name = when (scheme) {
|
||||
"content" -> runCatching {
|
||||
contentResolver.query(this, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
|
||||
?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
|
||||
}.getOrNull()
|
||||
else -> lastPathSegment
|
||||
} ?: return false
|
||||
return CALENDAR_EXTENSIONS.any { name.endsWith(it, ignoreCase = true) }
|
||||
}
|
||||
|
||||
/** A Long extra's value, or null when the extra is absent. */
|
||||
private fun Intent.longExtraOrNull(key: String): Long? =
|
||||
if (hasExtra(key)) getLongExtra(key, 0L) else null
|
||||
@@ -545,8 +504,6 @@ class MainActivity : AppCompatActivity() {
|
||||
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
|
||||
private const val CALENDAR_PROVIDER_HOST = "com.android.calendar"
|
||||
private const val SHARED_TEXT_TYPE = "text/plain"
|
||||
private val CALENDAR_TYPES = setOf("text/calendar", "text/x-vcalendar", "application/ics")
|
||||
private val CALENDAR_EXTENSIONS = listOf(".ics", ".vcs")
|
||||
private const val KEY_INSERT_FORM_OPEN = "insert_form_open"
|
||||
private const val KEY_LAUNCHED_FOR_INSERT = "launched_for_insert"
|
||||
|
||||
@@ -639,6 +596,3 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The theme choices the window needs before the settings screen's state loads. */
|
||||
private data class Appearance(val themeMode: ThemeMode, val dynamicColor: Boolean?)
|
||||
|
||||
@@ -56,10 +56,6 @@ import javax.inject.Singleton
|
||||
interface CalendarDataSource {
|
||||
fun calendars(): List<CalendarSource>
|
||||
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
||||
|
||||
/** [instances] of event [eventId] alone, soonest first. */
|
||||
fun eventInstances(eventId: Long, beginMillis: Long, endMillis: Long): List<EventInstance> =
|
||||
instances(beginMillis, endMillis).filter { it.eventId == eventId }
|
||||
/**
|
||||
* [allDayReminderTimeMinutes]: the hour all-day reminders fire at, needed to
|
||||
* decode their stored offsets back to whole-day lead times (see
|
||||
@@ -567,33 +563,21 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
|
||||
queryInstances(beginMillis, endMillis, null, null)
|
||||
|
||||
override fun eventInstances(eventId: Long, beginMillis: Long, endMillis: Long): List<EventInstance> =
|
||||
queryInstances(beginMillis, endMillis, "${CalendarContract.Instances.EVENT_ID} = ?", arrayOf(eventId.toString()))
|
||||
|
||||
private fun queryInstances(
|
||||
beginMillis: Long,
|
||||
endMillis: Long,
|
||||
extraSelection: String?,
|
||||
args: Array<String>?,
|
||||
): List<EventInstance> {
|
||||
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
|
||||
ensureObserversRegistered()
|
||||
// Hide cancelled occurrences: "delete only this event" writes a
|
||||
// cancelled exception for the one instance (#47). A NULL status is a
|
||||
// normal, un-cancelled event, so it must survive the filter — a bare
|
||||
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
|
||||
// DELETED = 0: a provider that soft-deletes may keep the row's
|
||||
// instances until a purge (#364).
|
||||
val visible = "(${CalendarContract.Instances.STATUS} IS NULL OR " +
|
||||
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}) AND " +
|
||||
"${CalendarContract.Events.DELETED} = 0"
|
||||
return resolver.query(
|
||||
instancesUri(beginMillis, endMillis),
|
||||
InstanceProjection.COLUMNS,
|
||||
if (extraSelection == null) visible else "$visible AND $extraSelection",
|
||||
args,
|
||||
// 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,
|
||||
CalendarContract.Instances.BEGIN + " ASC",
|
||||
)?.use { c -> c.mapRowsNotNull { toEventInstance() } } ?: emptyList()
|
||||
}
|
||||
@@ -741,7 +725,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
),
|
||||
null,
|
||||
)?.use { c ->
|
||||
c.mapRows { EventColorOption(key = getString(0).orEmpty(), argb = getInt(1)) }
|
||||
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
|
||||
}
|
||||
?.filter { it.key.isNotEmpty() }
|
||||
?: emptyList()
|
||||
@@ -1132,7 +1116,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
?: throw WriteFailedException("read modified occurrence id=${ex.exceptionEventId}")
|
||||
val endMillis = ex.dtEndMillis ?: (ex.dtStartMillis + parseRfc2445DurationMillis(ex.duration))
|
||||
val form = detail.toEditForm(ex.dtStartMillis, endMillis, zone)
|
||||
.copy(calendarId = targetCalendarId, colorKey = null)
|
||||
.copy(calendarId = targetCalendarId)
|
||||
.toDetachedOccurrence()
|
||||
detached += insertEvent(form, allDayReminderTimeMinutes)
|
||||
}
|
||||
@@ -1707,6 +1691,11 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
null,
|
||||
)?.use { c -> c.mapRows { toReminder() } } ?: emptyList()
|
||||
|
||||
/** Iterate every row and map; skips nothing. */
|
||||
private inline fun <T> Cursor.mapAll(mapper: (Cursor) -> T): List<T> = buildList {
|
||||
while (moveToNext()) add(mapper(this@mapAll))
|
||||
}
|
||||
|
||||
/** A fresh globally-unique `UID_2445`, for an event that carries none of its own. */
|
||||
private fun mintUid(): String = "${UUID.randomUUID()}@calendula"
|
||||
|
||||
|
||||
@@ -16,15 +16,8 @@ interface CalendarRepository {
|
||||
fun calendars(): Flow<List<CalendarSource>>
|
||||
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
|
||||
|
||||
/** One read of event [eventId]'s occurrences overlapping [range], soonest first, whatever calendar it is in. */
|
||||
suspend fun eventOccurrences(eventId: Long, range: ClosedRange<Instant>): List<EventInstance>
|
||||
|
||||
/** Re-query every open [calendars] / [instances] flow. */
|
||||
fun refresh()
|
||||
|
||||
/** Emits on the app's own writes and visibility changes; not on provider notifications or [refresh]. */
|
||||
val localChanges: Flow<Unit>
|
||||
|
||||
suspend fun eventDetail(eventId: Long): EventDetail
|
||||
|
||||
/**
|
||||
|
||||
@@ -78,28 +78,15 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
ticks.tryEmit(Unit)
|
||||
}
|
||||
|
||||
private val writes = MutableSharedFlow<Unit>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
/** Run a provider write on [io], then re-query whether or not it threw. */
|
||||
private suspend fun <T> write(block: suspend () -> T): T = withContext(io) {
|
||||
try {
|
||||
block()
|
||||
} finally {
|
||||
refresh()
|
||||
writes.tryEmit(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
override val localChanges: Flow<Unit> = merge(
|
||||
writes,
|
||||
prefs.pendingDisabledCalendarIds.distinctUntilChanged().drop(1).map {},
|
||||
prefs.hiddenCalendarIds.distinctUntilChanged().drop(1).map {},
|
||||
)
|
||||
|
||||
/**
|
||||
* Re-query signal for everything filtered by visibility: the provider's own
|
||||
* notifications, plus every change to the pending switch-off set.
|
||||
@@ -194,11 +181,6 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
cachedCalendars
|
||||
}
|
||||
|
||||
override suspend fun eventOccurrences(eventId: Long, range: ClosedRange<Instant>): List<EventInstance> =
|
||||
withContext(io) {
|
||||
dataSource.eventInstances(eventId, range.start.toEpochMillis(), range.endInclusive.toEpochMillis())
|
||||
}
|
||||
|
||||
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
|
||||
dataSource.eventDetail(eventId, allDayReminderTimeMinutes())
|
||||
?: throw NoSuchEventException(eventId)
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.data.di
|
||||
|
||||
import javax.inject.Qualifier
|
||||
|
||||
/** The process-wide `CoroutineScope` that app-lifetime singletons share their flows in. */
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class ApplicationScope
|
||||
@@ -24,9 +24,6 @@ import de.jeanlucmakiola.calendula.data.time.AndroidTodaySource
|
||||
import de.jeanlucmakiola.calendula.data.time.AndroidZoneSource
|
||||
import de.jeanlucmakiola.calendula.data.time.TodaySource
|
||||
import de.jeanlucmakiola.calendula.data.time.ZoneSource
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.calendulaDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
@@ -88,9 +85,4 @@ object DataProvideModule {
|
||||
@Singleton
|
||||
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||
context.calendulaDataStore
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@ApplicationScope
|
||||
fun provideApplicationScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
}
|
||||
|
||||
@@ -154,6 +154,8 @@ class SettingsPrefs @Inject constructor(
|
||||
val dragToReschedule: Flow<Boolean> = prefs.flow(SettingsKeys.dragToReschedule)
|
||||
val tapOpensEditor: Flow<Boolean> = prefs.flow(SettingsKeys.tapOpensEditor)
|
||||
val agendaScreenRange: Flow<AgendaRange> = prefs.flow(SettingsKeys.agendaScreenRange)
|
||||
val agendaWidgetRange: Flow<AgendaRange> = prefs.flow(SettingsKeys.agendaWidgetRange)
|
||||
val widgetSize: Flow<WidgetSize> = prefs.flow(SettingsKeys.widgetSize)
|
||||
val agendaShowRangeBar: Flow<Boolean> = prefs.flow(SettingsKeys.agendaShowRangeBar)
|
||||
val agendaShowToday: Flow<Boolean> = prefs.flow(SettingsKeys.agendaShowToday)
|
||||
val defaultView: Flow<CalendarView> = prefs.flow(SettingsKeys.defaultView)
|
||||
@@ -229,6 +231,8 @@ 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)
|
||||
@@ -493,16 +497,13 @@ object SettingsKeys {
|
||||
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
||||
val agendaScreenRange: Pref<AgendaRange> = agendaRangePref("agenda_screen_range")
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** How far ahead the agenda **widget** shows events (v2.11). */
|
||||
val agendaWidgetRange: Pref<AgendaRange> = agendaRangePref("agenda_widget_range")
|
||||
|
||||
/**
|
||||
* The size step the agenda widget draws its text at (#51). [WidgetSize.SMALL]
|
||||
* reproduces its original metrics. Read-only like [agendaWidgetRange] (#292).
|
||||
* reproduces its original metrics. The month widget takes no size setting —
|
||||
* it divides the width it is given by seven (#103).
|
||||
*/
|
||||
val widgetSize: Pref<WidgetSize> = enumPref("widget_size", WidgetSize.SMALL)
|
||||
|
||||
@@ -510,8 +511,8 @@ object SettingsKeys {
|
||||
val agendaShowRangeBar: Pref<Boolean> = booleanPref("agenda_show_range_bar", true)
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Whether the agenda (screen and widget) always anchors today at the top with
|
||||
* a "nothing left today" placeholder (issue #35).
|
||||
*/
|
||||
val agendaShowToday: Pref<Boolean> = booleanPref("agenda_show_today", true)
|
||||
|
||||
|
||||
@@ -3,15 +3,12 @@ package de.jeanlucmakiola.calendula.data.time
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.di.ApplicationScope
|
||||
import de.jeanlucmakiola.floret.android.broadcastTicks
|
||||
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
@@ -30,12 +27,10 @@ interface TodaySource {
|
||||
/**
|
||||
* Re-reads the date on every minute boundary while collected, and at once on the
|
||||
* system's date, time and zone broadcasts, which can arrive late or not at all.
|
||||
* One receiver and tick loop is shared by every collector.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AndroidTodaySource @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@ApplicationScope scope: CoroutineScope,
|
||||
) : TodaySource {
|
||||
|
||||
override val today: Flow<LocalDate> = context
|
||||
@@ -47,7 +42,6 @@ class AndroidTodaySource @Inject constructor(
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.shareIn(scope, SharingStarted.WhileSubscribed(replayExpirationMillis = 0), replay = 1)
|
||||
}
|
||||
|
||||
private const val MINUTE_MILLIS = 60_000L
|
||||
|
||||
@@ -3,14 +3,10 @@ package de.jeanlucmakiola.calendula.data.time
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.di.ApplicationScope
|
||||
import de.jeanlucmakiola.floret.android.broadcastTicks
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.shareIn
|
||||
import kotlinx.datetime.TimeZone
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -27,16 +23,14 @@ interface ZoneSource {
|
||||
fun current(): TimeZone
|
||||
}
|
||||
|
||||
/** Follows the system's zone broadcast, one receiver shared by every collector. */
|
||||
/** Follows the system's zone broadcast. */
|
||||
class AndroidZoneSource @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@ApplicationScope scope: CoroutineScope,
|
||||
) : ZoneSource {
|
||||
|
||||
override val zone: Flow<TimeZone> = context.broadcastTicks(Intent.ACTION_TIMEZONE_CHANGED)
|
||||
.map { current() }
|
||||
.distinctUntilChanged()
|
||||
.shareIn(scope, SharingStarted.WhileSubscribed(replayExpirationMillis = 0), replay = 1)
|
||||
|
||||
override fun current(): TimeZone = TimeZone.currentSystemDefault()
|
||||
}
|
||||
|
||||
@@ -131,10 +131,6 @@ private class HtmlRenderer(private val html: String) {
|
||||
text("<")
|
||||
return lt + 1
|
||||
}
|
||||
if (!closing && isAutolink(html.substring(nameStart, end))) {
|
||||
text(html.substring(lt, end + 1))
|
||||
return end + 1
|
||||
}
|
||||
var nameEnd = nameStart
|
||||
while (nameEnd < end && (html[nameEnd].isLetterOrDigit() || html[nameEnd] == ':')) nameEnd++
|
||||
val name = html.substring(nameStart, nameEnd).lowercase()
|
||||
@@ -153,11 +149,6 @@ private class HtmlRenderer(private val html: String) {
|
||||
return end + 1
|
||||
}
|
||||
|
||||
/** An angle-bracketed URL or address (`<https://…>`, `<a@b.c>`), kept as text. */
|
||||
private fun isAutolink(inner: String): Boolean =
|
||||
LINK_SCHEMES.any { inner.startsWith(it, ignoreCase = true) } ||
|
||||
('@' in inner && inner.none { it.isWhitespace() || it == '=' })
|
||||
|
||||
/** The `>` closing the tag at [lt], skipping any inside quoted attributes. */
|
||||
private fun tagEnd(lt: Int): Int {
|
||||
var quote: Char? = null
|
||||
|
||||
@@ -66,9 +66,7 @@ data class SanitizedRrule(val rule: String?, val repaired: Boolean)
|
||||
* rule at all — gives up entirely.
|
||||
*/
|
||||
fun sanitizeRrule(raw: String?): SanitizedRrule {
|
||||
val rule = raw?.trim()
|
||||
?.let { if (it.startsWith("RRULE:", ignoreCase = true)) it.drop("RRULE:".length) else it }
|
||||
?.trim().orEmpty()
|
||||
val rule = raw?.trim()?.removePrefix("RRULE:")?.trim().orEmpty()
|
||||
if (rule.isEmpty()) return SanitizedRrule(rule = null, repaired = false)
|
||||
|
||||
var freqSeen = false
|
||||
@@ -86,8 +84,8 @@ fun sanitizeRrule(raw: String?): SanitizedRrule {
|
||||
// A stray separator ("FREQ=DAILY;") normalises away; nothing was lost.
|
||||
// A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part
|
||||
// being dropped, so it falls through and marks the rule repaired.
|
||||
for (part in rruleParts(rule)) {
|
||||
val key = part.key?.trim()?.uppercase().orEmpty()
|
||||
for (part in rruleParts(raw.orEmpty().trim())) {
|
||||
val key = part.key?.trim().orEmpty()
|
||||
val value = part.value.trim()
|
||||
val kept = when {
|
||||
key.isEmpty() || value.isEmpty() -> null
|
||||
|
||||
@@ -7,7 +7,6 @@ import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.animation.core.MutableTransitionState
|
||||
import androidx.compose.animation.core.SeekableTransitionState
|
||||
import androidx.compose.animation.core.rememberTransition
|
||||
import androidx.compose.animation.fadeIn
|
||||
@@ -35,7 +34,6 @@ import androidx.compose.runtime.saveable.listSaver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.Modifier
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberHeld
|
||||
import de.jeanlucmakiola.calendula.ui.common.screen
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewScreen
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
@@ -68,7 +66,7 @@ import de.jeanlucmakiola.calendula.ui.common.EventSelectionHost
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventActionFailureMessage
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberFabSelection
|
||||
import de.jeanlucmakiola.calendula.ui.common.canWriteCalendar
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGateOr
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
||||
@@ -245,29 +243,20 @@ fun CalendarHost(
|
||||
// An opened/received .ics file. [ImportScreen] parses it and either opens
|
||||
// the prefilled create form (one event → [importForm]) or its own bulk
|
||||
// picker (many). A plain conditional overlay (no slide) — it's transient.
|
||||
var importUri by rememberSaveable { mutableStateOf<android.net.Uri?>(null) }
|
||||
val importViewModel: EventEditViewModel = hiltViewModel(key = "import")
|
||||
// Saved as "open"; a recreation takes the form back from the ViewModel, which
|
||||
// still holds the user's edits.
|
||||
var importForm by rememberSaveable(
|
||||
stateSaver = Saver<EventForm?, Boolean>(
|
||||
save = { it != null },
|
||||
restore = { open -> if (open) importViewModel.currentForm() else null },
|
||||
),
|
||||
) { mutableStateOf<EventForm?>(null) }
|
||||
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
|
||||
var importForm by remember { mutableStateOf<EventForm?>(null) }
|
||||
// Which channel filled [importForm]: an .ics file (prompt to apply the default
|
||||
// reminder) or an ACTION_INSERT intent (apply it automatically) — #49.
|
||||
var importFormSource by rememberSaveable { mutableStateOf(ImportSource.File) }
|
||||
var importFormSource by remember { mutableStateOf(ImportSource.File) }
|
||||
// Bumped per open, so even an equal prefill starts a fresh form.
|
||||
var importFormId by rememberSaveable { mutableIntStateOf(0) }
|
||||
var importFormId by remember { mutableIntStateOf(0) }
|
||||
// Whether [importForm] came from an external create request (#30, #306);
|
||||
// closing it then hands control back to whoever asked.
|
||||
var importFromRequest by rememberSaveable { mutableStateOf(false) }
|
||||
// A second external request that arrived while a prefilled form was open:
|
||||
// it waits behind that form's unsaved-changes prompt, then opens.
|
||||
var pendingInsert by remember { mutableStateOf<Pair<EventForm, ImportSource>?>(null) }
|
||||
val createViewModel: EventEditViewModel = hiltViewModel(key = "create")
|
||||
val editViewModel: EventEditViewModel = hiltViewModel(key = "edit")
|
||||
val importViewModel: EventEditViewModel = hiltViewModel(key = "import")
|
||||
fun openImportForm(form: EventForm, source: ImportSource, fromRequest: Boolean = false) {
|
||||
importFormSource = source
|
||||
importForm = form
|
||||
@@ -291,7 +280,7 @@ fun CalendarHost(
|
||||
// flow — picker + summary — even for a single-event file, because the intent
|
||||
// is "restore a backup", not "add this one event". An externally opened .ics
|
||||
// keeps routing a single event straight into the prefilled create form.
|
||||
var importForceMany by rememberSaveable { mutableStateOf(false) }
|
||||
var importForceMany by remember { mutableStateOf(false) }
|
||||
// Where a restore came from, so closing the import puts it back: the import
|
||||
// overlays are declared under Backup & restore and the manager, so starting
|
||||
// one has to close them — without this, finishing a restore drops you on the
|
||||
@@ -302,20 +291,9 @@ fun CalendarHost(
|
||||
// what tells it a *re-import of the same file* is new work and not the run
|
||||
// it already finished; a rotation keeps the number and keeps the result.
|
||||
var importSession by rememberSaveable { mutableStateOf(0) }
|
||||
// A recreation re-reads the launch intent; when the import it carries came
|
||||
// back with the saved state, that replay is the same work, not a new one.
|
||||
fun isReplayOfRestoredImport(): Boolean {
|
||||
val replay = requests.replayed && (importUri != null || importForm != null)
|
||||
requests.replayed = false
|
||||
return replay
|
||||
}
|
||||
val requestedImportUri = requests.importUri
|
||||
LaunchedEffect(requestedImportUri) {
|
||||
if (requestedImportUri != null) {
|
||||
if (isReplayOfRestoredImport()) {
|
||||
requests.importUri = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
importUri = requestedImportUri
|
||||
importForceMany = false
|
||||
importSession++
|
||||
@@ -329,10 +307,6 @@ fun CalendarHost(
|
||||
val requestedInsert = requests.insert
|
||||
LaunchedEffect(requestedInsert) {
|
||||
if (requestedInsert != null) {
|
||||
if (isReplayOfRestoredImport()) {
|
||||
requests.insert = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (importForm == null) {
|
||||
openImportForm(requestedInsert.form, requestedInsert.source, fromRequest = true)
|
||||
} else {
|
||||
@@ -344,22 +318,16 @@ fun CalendarHost(
|
||||
|
||||
// Close every overlay that can sit over the calendar, so an externally
|
||||
// requested destination (a widget/shortcut/QS-tile launch) is revealed on
|
||||
// top instead of underneath whatever the user had open. The create form,
|
||||
// declared under the edit form, can stay for an edit to open over it.
|
||||
fun dismissCoveringOverlays(keepCreate: Boolean = false) {
|
||||
// top instead of underneath whatever the user had open.
|
||||
fun dismissCoveringOverlays() {
|
||||
showSettings = false
|
||||
showSearch = false
|
||||
showCalendars = false
|
||||
showBackup = false
|
||||
detailKey = null
|
||||
editKey = null
|
||||
if (!keepCreate) createRequest = null
|
||||
importUri = null
|
||||
importForm = null
|
||||
if (importFromRequest) {
|
||||
importFromRequest = false
|
||||
requests.onExternalFormClosed()
|
||||
}
|
||||
importFromRequest = false
|
||||
pendingInsert = null
|
||||
importViewModel.reset()
|
||||
}
|
||||
@@ -372,10 +340,7 @@ fun CalendarHost(
|
||||
val requestedEditKey = requests.editKey
|
||||
LaunchedEffect(requestedEditKey) {
|
||||
if (requestedEditKey != null) {
|
||||
// An open edit form stays on screen, so it starts over here rather
|
||||
// than once it has slid away.
|
||||
if (editKey != null) editViewModel.reset()
|
||||
dismissCoveringOverlays(keepCreate = true)
|
||||
dismissCoveringOverlays()
|
||||
editKey = requestedEditKey
|
||||
requests.editKey = null
|
||||
}
|
||||
@@ -396,6 +361,7 @@ fun CalendarHost(
|
||||
// Drill the day view in over the widget's view: drop any overlay
|
||||
// that would cover it, so the open doesn't land under Settings/form.
|
||||
dismissCoveringOverlays()
|
||||
createRequest = null
|
||||
focus.navigateTo(LocalDate.parse(req.dateIso))
|
||||
// No widget source (an external date tap) roots over the default
|
||||
// home view, so backing out of the day returns home then exits.
|
||||
@@ -406,6 +372,7 @@ fun CalendarHost(
|
||||
// Root the stack in the widget's view, then open the occurrence
|
||||
// detail over it (same key shape as a tapped event / reminder).
|
||||
dismissCoveringOverlays()
|
||||
createRequest = null
|
||||
viewStack = viewBaseStack(defaultView, req.source)
|
||||
detailKey = longArrayOf(req.eventId, req.beginMillis, req.endMillis)
|
||||
requests.nav = null
|
||||
@@ -416,6 +383,7 @@ fun CalendarHost(
|
||||
// the stack on the target (null → the default home) over the
|
||||
// default home — so backing out returns to the default, then exits.
|
||||
dismissCoveringOverlays()
|
||||
createRequest = null
|
||||
focus.navigateTo(currentDate())
|
||||
viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
|
||||
requests.nav = null
|
||||
@@ -425,7 +393,6 @@ fun CalendarHost(
|
||||
// widget) must land on top of whatever is open — the form overlay
|
||||
// sits below Settings/calendars in the Box, so without this it
|
||||
// would open hidden underneath them.
|
||||
if (createRequest != null) createViewModel.reset()
|
||||
dismissCoveringOverlays()
|
||||
openCreate(req.dateIso ?: currentDate().toString())
|
||||
requests.nav = null
|
||||
@@ -447,22 +414,9 @@ fun CalendarHost(
|
||||
// tap time, so a tap before they've loaded still routes right.
|
||||
val tapScope = rememberCoroutineScope()
|
||||
val tapContext = LocalContext.current
|
||||
val requireWriteOr = rememberWriteGateOr()
|
||||
// A refused write upgrade is asked once; later taps just open the details.
|
||||
var tapWriteRefused by rememberSaveable { mutableStateOf(false) }
|
||||
// The tap waiting on the WRITE_CALENDAR prompt, saved so a rotation keeps it.
|
||||
var tapAwaitingWrite by rememberSaveable { mutableStateOf<LongArray?>(null) }
|
||||
val editAwaitingTap = rememberWriteAction(
|
||||
onDenied = {
|
||||
tapAwaitingWrite?.let { key ->
|
||||
tapWriteRefused = true
|
||||
detailKey = key
|
||||
}
|
||||
tapAwaitingWrite = null
|
||||
},
|
||||
) {
|
||||
tapAwaitingWrite?.let { editKey = it }
|
||||
tapAwaitingWrite = null
|
||||
}
|
||||
val onEventClick: (EventInstance) -> Unit = { event ->
|
||||
tapScope.launch {
|
||||
val toEditor = viewModel.tapOpensEditor() && eventActions.canModify(event.calendarId)
|
||||
@@ -470,9 +424,9 @@ fun CalendarHost(
|
||||
!toEditor -> onOpenDetail(event)
|
||||
tapContext.canWriteCalendar() -> onEditEvent(event)
|
||||
tapWriteRefused -> onOpenDetail(event)
|
||||
else -> {
|
||||
tapAwaitingWrite = event.occurrenceKey()
|
||||
editAwaitingTap()
|
||||
else -> requireWriteOr({ onEditEvent(event) }) {
|
||||
tapWriteRefused = true
|
||||
onOpenDetail(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -720,26 +674,24 @@ fun CalendarHost(
|
||||
|
||||
// Event form (v1.2) — full-screen destination, slides over the calendar.
|
||||
val shownCreate = rememberHeld(createRequest)
|
||||
SlideOverlay(visible = createRequest != null, onHidden = createViewModel::reset) {
|
||||
SlideOverlay(visible = createRequest != null) {
|
||||
shownCreate?.let { create ->
|
||||
key(create) {
|
||||
EventEditScreen(
|
||||
initialDateIso = create.dateIso,
|
||||
initialStartMinutes = create.startMinutes,
|
||||
initialDraft = create.draftMillis,
|
||||
onClose = { createRequest = null },
|
||||
onSaved = { createRequest = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
visible = createRequest != null,
|
||||
viewModel = createViewModel,
|
||||
)
|
||||
}
|
||||
EventEditScreen(
|
||||
initialDateIso = create.dateIso,
|
||||
initialStartMinutes = create.startMinutes,
|
||||
initialDraft = create.draftMillis,
|
||||
onClose = { createRequest = null },
|
||||
onSaved = { createRequest = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
visible = createRequest != null,
|
||||
viewModel = hiltViewModel(key = "create"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Edit form (v1.3) — slides over the detail screen.
|
||||
val shownEditKey = rememberHeld(editKey)
|
||||
SlideOverlay(visible = editKey != null, onHidden = editViewModel::reset) {
|
||||
SlideOverlay(visible = editKey != null) {
|
||||
shownEditKey?.let { key ->
|
||||
EventEditScreen(
|
||||
initialDateIso = null,
|
||||
@@ -760,7 +712,7 @@ fun CalendarHost(
|
||||
if (detailKey == null) detailKey = key
|
||||
},
|
||||
visible = editKey != null,
|
||||
viewModel = editViewModel,
|
||||
viewModel = hiltViewModel(key = "edit"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -878,25 +830,33 @@ private val viewStackSaver = listSaver<List<CalendarView>, Int>(
|
||||
* snapping back and sliding off.
|
||||
*/
|
||||
@Composable
|
||||
private fun SlideOverlay(
|
||||
visible: Boolean,
|
||||
onHidden: () -> Unit = {},
|
||||
content: @Composable AnimatedVisibilityScope.() -> Unit,
|
||||
) {
|
||||
val state = remember { MutableTransitionState(visible) }
|
||||
state.targetState = visible
|
||||
val hidden = state.isIdle && !state.currentState
|
||||
LaunchedEffect(hidden) {
|
||||
if (hidden) onHidden()
|
||||
}
|
||||
private fun SlideOverlay(visible: Boolean, content: @Composable AnimatedVisibilityScope.() -> Unit) {
|
||||
AnimatedVisibility(
|
||||
visibleState = state,
|
||||
visible = visible,
|
||||
enter = slideInHorizontally(rememberCalendarSlideSpec()) { it } + fadeIn(),
|
||||
exit = predictiveBackExit(),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* [live], or after it goes null the last value it had, so an overlay keeps
|
||||
* drawing what it showed through its slide-out instead of flashing empty. Never
|
||||
* a placeholder: until [live] is first set this is null, so a destination never
|
||||
* loads a bogus key on its first frame. Not saved — after a recreation the
|
||||
* saveable [live] is the value again.
|
||||
*/
|
||||
@Composable
|
||||
private fun <T : Any> rememberHeld(live: T?): T? {
|
||||
val held = remember { HeldValue<T>() }
|
||||
if (live != null) held.value = live
|
||||
return live ?: held.value
|
||||
}
|
||||
|
||||
private class HeldValue<T : Any> {
|
||||
var value: T? = null
|
||||
}
|
||||
|
||||
/** The detail and edit overlays' key for one occurrence: eventId, begin, end. */
|
||||
private fun EventInstance.occurrenceKey(): LongArray =
|
||||
longArrayOf(eventId, start.toEpochMilliseconds(), end.toEpochMilliseconds())
|
||||
|
||||
@@ -36,9 +36,6 @@ class ExternalRequests(val onExternalFormClosed: () -> Unit = {}) {
|
||||
|
||||
/** An external "edit this event" (ACTION_EDIT), in the detail key's shape. */
|
||||
var editKey by mutableStateOf<LongArray?>(null)
|
||||
|
||||
/** Whether these came from re-reading the launch intent on a recreation. */
|
||||
var replayed = false
|
||||
}
|
||||
|
||||
/** A prefilled create form from an external launch, with the source it came from. */
|
||||
|
||||
@@ -393,8 +393,11 @@ private fun AgendaList(
|
||||
val itemDates = agendaItemDates(days)
|
||||
val anchorRow = itemDates.indexOfFirst { it >= anchor }.coerceAtLeast(0)
|
||||
SideEffect { anchorIndex.intValue = anchorRow }
|
||||
// A new anchor opens on its row from the first frame; keyed on the anchor
|
||||
// the days arrived with, not the one requested. The first run may restore.
|
||||
// A new anchor opens the list on its row from the first frame, with past
|
||||
// days above. Keyed on the anchor the days arrived with, not the one just
|
||||
// requested: scrolling before the new rows compose leaves the list keyed to
|
||||
// a row that reappears further down, and it follows it there. The first run
|
||||
// may restore a remembered day instead.
|
||||
val pendingRestore = remember { arrayOf(initialTopDate) }
|
||||
DisposableEffect(anchor) {
|
||||
val target = pendingRestore[0]
|
||||
|
||||
@@ -163,17 +163,15 @@ class AgendaViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend the window further back (#367), doubling what's loaded (at least
|
||||
* [PAST_CHUNK_DAYS], at most [MAX_PAST_STEP_DAYS]) so a long scroll back
|
||||
* re-queries only a few times. Ignored while the previous extension is
|
||||
* still loading.
|
||||
* Extend the window [PAST_CHUNK_DAYS] further back (#367). Ignored while the
|
||||
* previous extension is still loading, so a scroll that keeps asking only
|
||||
* moves one chunk at a time.
|
||||
*/
|
||||
fun loadEarlier() {
|
||||
val loaded = (state.value as? AgendaUiState.Success)?.windowStart ?: return
|
||||
val window = _window.value
|
||||
if (loaded != window.start) return
|
||||
val step = window.pastDays.coerceIn(PAST_CHUNK_DAYS, MAX_PAST_STEP_DAYS)
|
||||
_window.value = window.copy(pastDays = window.pastDays + step)
|
||||
_window.value = window.copy(pastDays = window.pastDays + PAST_CHUNK_DAYS)
|
||||
}
|
||||
|
||||
fun rememberTopDay(date: LocalDate) {
|
||||
@@ -235,9 +233,6 @@ class AgendaViewModel @Inject constructor(
|
||||
/** How many days each scroll back into the past loads (#367). */
|
||||
internal const val PAST_CHUNK_DAYS = 30
|
||||
|
||||
/** The most one scroll back adds, so an empty past grows the window by years, not exponentially. */
|
||||
internal const val MAX_PAST_STEP_DAYS = 365
|
||||
|
||||
/** Inclusive instant range from the start of [anchor] through [days] days ahead. */
|
||||
internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange<Instant> =
|
||||
dayRange(anchor, anchor.plus(days, DateTimeUnit.DAY), zone)
|
||||
|
||||
@@ -26,6 +26,8 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
@@ -80,7 +82,8 @@ fun CalendarFabColumn(
|
||||
)
|
||||
}
|
||||
// Held past the selection clearing, so the bar has something to fold away.
|
||||
val heldBar = rememberHeld(selection?.bar)
|
||||
val heldBar = remember { mutableStateOf<(@Composable () -> Unit)?>(null) }
|
||||
if (selection != null && heldBar.value !== selection.bar) heldBar.value = selection.bar
|
||||
// Held at the FAB's height so the taller bar can't shift the FAB.
|
||||
Row(
|
||||
modifier = Modifier.height(FabSize),
|
||||
@@ -98,7 +101,7 @@ fun CalendarFabColumn(
|
||||
shrinkTowards = Alignment.End,
|
||||
) + fadeOut(),
|
||||
) {
|
||||
heldBar?.invoke()
|
||||
heldBar.value?.invoke()
|
||||
}
|
||||
val label = stringResource(
|
||||
when {
|
||||
|
||||
@@ -17,7 +17,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -101,28 +100,33 @@ suspend fun CalendarRepository.tryDeleteEvent(
|
||||
}
|
||||
|
||||
/**
|
||||
* [action], which writes to the calendar, as a handler that asks for
|
||||
* `WRITE_CALENDAR` first where the install only holds read access (v1.0
|
||||
* installs). A grant continues into [action], also when it returns after a
|
||||
* rotation; a refusal runs [onDenied].
|
||||
* Runs an action that writes to the calendar, asking for `WRITE_CALENDAR` first
|
||||
* where the install only holds read access (v1.0 installs). A grant continues
|
||||
* straight into the action that asked for it.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberWriteAction(onDenied: () -> Unit = {}, action: () -> Unit): () -> Unit {
|
||||
fun rememberWriteGate(): (() -> Unit) -> Unit {
|
||||
val gate = rememberWriteGateOr()
|
||||
return { action -> gate(action) {} }
|
||||
}
|
||||
|
||||
/** [rememberWriteGate], running the fallback instead when the user refuses. */
|
||||
@Composable
|
||||
fun rememberWriteGateOr(): (action: () -> Unit, fallback: () -> Unit) -> Unit {
|
||||
val context = LocalContext.current
|
||||
val currentAction by rememberUpdatedState(action)
|
||||
val currentDenied by rememberUpdatedState(onDenied)
|
||||
var pending by remember { mutableStateOf<Pair<() -> Unit, () -> Unit>?>(null) }
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (granted) currentAction() else currentDenied()
|
||||
pending?.let { (action, fallback) -> if (granted) action() else fallback() }
|
||||
pending = null
|
||||
}
|
||||
return remember(context, launcher) {
|
||||
{
|
||||
if (context.canWriteCalendar()) {
|
||||
currentAction()
|
||||
} else {
|
||||
launcher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||
}
|
||||
return { action, fallback ->
|
||||
if (context.canWriteCalendar()) {
|
||||
action()
|
||||
} else {
|
||||
pending = action to fallback
|
||||
launcher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.time.ZoneSource
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
@@ -27,6 +26,7 @@ import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -42,7 +42,6 @@ class EventActionsViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
private val icsExporter: IcsExporter,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
private val zoneSource: ZoneSource,
|
||||
) : ViewModel() {
|
||||
|
||||
/** Every calendar by id, for what a selected event's bar may offer. */
|
||||
@@ -65,7 +64,7 @@ class EventActionsViewModel @Inject constructor(
|
||||
suspend fun duplicateForm(event: EventInstance): EventForm? {
|
||||
val detail = load(event) ?: return null
|
||||
val canModify = calendars.value[event.calendarId]?.canModifyContents == true
|
||||
return detail.toDuplicateForm(canModify, zoneSource.current())
|
||||
return detail.toDuplicateForm(canModify, TimeZone.currentSystemDefault())
|
||||
}
|
||||
|
||||
/** A staged `.ics` for [event], or null — reported — when it can't be built. */
|
||||
|
||||
@@ -32,6 +32,7 @@ fun rememberFabSelection(
|
||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val requireWrite = rememberWriteGate()
|
||||
val chooserTitle = stringResource(R.string.event_share_chooser_title)
|
||||
|
||||
// What the FAB has been told for this selection, over what the coverage says.
|
||||
@@ -42,28 +43,10 @@ fun rememberFabSelection(
|
||||
val currentEdit by rememberUpdatedState(onEdit)
|
||||
val currentDuplicate by rememberUpdatedState(onDuplicate)
|
||||
val currentDismissChip by rememberUpdatedState(onDismissChip)
|
||||
// Each reads the selection at grant time, which may come after a rotation.
|
||||
val onCreate = rememberWriteAction { host.selection?.create?.invoke() }
|
||||
val onEditSelected = rememberWriteAction {
|
||||
host.selection?.takeIf { it.create == null }?.let { selected ->
|
||||
selected.dismiss()
|
||||
currentEdit(selected.event)
|
||||
}
|
||||
}
|
||||
val onDuplicateSelected = rememberWriteAction {
|
||||
val selected = host.selection?.takeIf { it.create == null } ?: return@rememberWriteAction
|
||||
scope.launch {
|
||||
val form = viewModel.duplicateForm(selected.event) ?: return@launch
|
||||
selected.dismiss()
|
||||
currentDuplicate(form)
|
||||
}
|
||||
}
|
||||
val onDeleteSelected = rememberWriteAction {
|
||||
host.selection?.takeIf { it.create == null }?.let { viewModel.requestDelete(it.event) }
|
||||
}
|
||||
val currentWrite by rememberUpdatedState(requireWrite)
|
||||
|
||||
val selection = host.selection ?: return null
|
||||
if (selection.create != null) {
|
||||
selection.create?.let { create ->
|
||||
return remember(selection, expanded, chipShown) {
|
||||
FabSelection(
|
||||
expanded = expanded,
|
||||
@@ -73,7 +56,7 @@ fun rememberFabSelection(
|
||||
},
|
||||
bar = {
|
||||
DraftSelectionBar(
|
||||
onCreate = onCreate,
|
||||
onCreate = { currentWrite { create() } },
|
||||
onDiscard = selection.dismiss,
|
||||
)
|
||||
},
|
||||
@@ -95,15 +78,28 @@ fun rememberFabSelection(
|
||||
EventSelectionBar(
|
||||
canEdit = canEdit,
|
||||
canDelete = canDelete,
|
||||
onEdit = onEditSelected,
|
||||
onDuplicate = onDuplicateSelected,
|
||||
onEdit = {
|
||||
currentWrite {
|
||||
selection.dismiss()
|
||||
currentEdit(event)
|
||||
}
|
||||
},
|
||||
onDuplicate = {
|
||||
currentWrite {
|
||||
scope.launch {
|
||||
val form = viewModel.duplicateForm(event) ?: return@launch
|
||||
selection.dismiss()
|
||||
currentDuplicate(form)
|
||||
}
|
||||
}
|
||||
},
|
||||
onShare = {
|
||||
scope.launch {
|
||||
val uri = viewModel.shareUri(event) ?: return@launch
|
||||
if (!context.sendEventShare(uri, chooserTitle)) viewModel.reportShareFailed()
|
||||
}
|
||||
},
|
||||
onDelete = onDeleteSelected,
|
||||
onDelete = { currentWrite { viewModel.requestDelete(event) } },
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -20,6 +20,7 @@ import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
@@ -110,13 +111,14 @@ fun HourGutter(
|
||||
}
|
||||
}
|
||||
// Held while it fades, so it goes out where it was rather than blinking off.
|
||||
val badgeMin = rememberHeld(dragStartMin)
|
||||
val badgeMin = remember { mutableStateOf<Int?>(null) }
|
||||
dragStartMin?.let { badgeMin.value = it }
|
||||
val badgeIn by animateFloatAsState(
|
||||
targetValue = if (dragStartMin != null) 1f else 0f,
|
||||
animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
||||
label = "gutterBadge",
|
||||
)
|
||||
badgeMin?.takeIf { badgeIn > 0f }?.let { startMin ->
|
||||
badgeMin.value?.takeIf { badgeIn > 0f }?.let { startMin ->
|
||||
val top = (hourHeight * (startMin / 60f) - BADGE_HEIGHT / 2).coerceAtLeast(0.dp)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
|
||||
/**
|
||||
* [live], or after it goes null the last value it had, so an overlay keeps
|
||||
* drawing what it showed through its slide-out instead of flashing empty. Never
|
||||
* a placeholder: until [live] is first set this is null, so a destination never
|
||||
* loads a bogus key on its first frame. Not saved — after a recreation the
|
||||
* saveable [live] is the value again.
|
||||
*/
|
||||
@Composable
|
||||
fun <T : Any> rememberHeld(live: T?): T? {
|
||||
val held = remember { HeldValue<T>() }
|
||||
if (live != null) held.value = live
|
||||
return live ?: held.value
|
||||
}
|
||||
|
||||
private class HeldValue<T : Any> {
|
||||
var value: T? = null
|
||||
}
|
||||
@@ -101,10 +101,9 @@ fun TimeZonePickerDialog(
|
||||
onDismiss = onDismiss,
|
||||
scrollable = false,
|
||||
) {
|
||||
PickerSearchField(
|
||||
SearchField(
|
||||
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)) {
|
||||
@@ -182,10 +181,9 @@ fun TimeZonePickerDialog(
|
||||
* which has a top bar to put it in) because the picker's bar is the title.
|
||||
*/
|
||||
@Composable
|
||||
internal fun PickerSearchField(
|
||||
private fun SearchField(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
placeholder: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
@@ -208,7 +206,7 @@ internal fun PickerSearchField(
|
||||
InlineTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
placeholder = placeholder,
|
||||
placeholder = stringResource(R.string.event_edit_timezone_search),
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
imeAction = ImeAction.Search,
|
||||
onImeAction = { keyboard?.hide() },
|
||||
|
||||
@@ -626,8 +626,25 @@ class TimelineDragController {
|
||||
*/
|
||||
private var originSlot: Triple<LocalDate, Int, Int>? = null
|
||||
|
||||
/** The resize in progress, or null for a plain drag (#141). */
|
||||
private var resize: ResizeSession? = null
|
||||
/** The edge a resize is dragging, or null for a plain drag (#141). */
|
||||
private var resizeEdge: ResizeEdge? = null
|
||||
|
||||
/** The day the resized event starts on; its edges are minutes from that midnight. */
|
||||
private var resizeDay: LocalDate? = null
|
||||
private var resizeStartMin = 0
|
||||
private var resizeEndMin = 0
|
||||
|
||||
/** How far the finger sits past the edge it grabbed, so the edge doesn't jump to it. */
|
||||
private var resizeGrabMin = 0f
|
||||
|
||||
/** The column the resized edge is in, held until the finger is well into another. */
|
||||
private var resizeColumn: Int? = null
|
||||
|
||||
/** The slot the resized edge sits on, held against a finger trembling between two. */
|
||||
private var resizeSlot: Int? = null
|
||||
|
||||
/** The day, counted from the event's start day, the finger is resizing in. */
|
||||
private var resizeEdgeDay = 0
|
||||
|
||||
fun begin(
|
||||
block: TimedBlock,
|
||||
@@ -674,7 +691,10 @@ class TimelineDragController {
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
resize = null
|
||||
resizeEdge = null
|
||||
resizeDay = null
|
||||
resizeColumn = null
|
||||
resizeSlot = null
|
||||
source = null
|
||||
clipOffsetMin = 0
|
||||
dragZone = null
|
||||
@@ -780,9 +800,11 @@ class TimelineDragController {
|
||||
pointerInRoot: Offset,
|
||||
) {
|
||||
val span = wallClockSpan(block.event, zone)
|
||||
val session = ResizeSession(edge, span.day, span.startMin, span.endMin)
|
||||
source = block
|
||||
resize = session
|
||||
resizeEdge = edge
|
||||
resizeDay = span.day
|
||||
resizeStartMin = span.startMin
|
||||
resizeEndMin = span.endMin
|
||||
clipOffsetMin = 0
|
||||
this.titleLines = titleLines
|
||||
settling = null
|
||||
@@ -791,12 +813,12 @@ class TimelineDragController {
|
||||
liftedInstanceId = block.event.instanceId
|
||||
pointer = pointerInRoot
|
||||
// Measured from the edge's own column: the handle overhangs into the next.
|
||||
val edgeDay = if (edge == ResizeEdge.Top) 0 else (session.endMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0)
|
||||
val edgeDay = if (edge == ResizeEdge.Top) 0 else (resizeEndMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0)
|
||||
val edgeIndex = geometry.days.indexOf(span.day.plus(edgeDay, DateTimeUnit.DAY))
|
||||
session.column = edgeIndex.takeIf { it >= 0 }
|
||||
resizeColumn = edgeIndex.takeIf { it >= 0 }
|
||||
?.let { column(it) }
|
||||
session.grabMin = (pointerResizeMinutes(session) ?: 0f) -
|
||||
if (edge == ResizeEdge.Top) session.startMin else session.endMin
|
||||
resizeGrabMin = (pointerResizeMinutes() ?: 0f) -
|
||||
if (edge == ResizeEdge.Top) resizeStartMin else resizeEndMin
|
||||
originSlot = null
|
||||
recompute()
|
||||
originSlot = drag?.slot
|
||||
@@ -812,7 +834,7 @@ class TimelineDragController {
|
||||
fun preview(zone: TimeZone): EventInstance? {
|
||||
val (date, startMin, spanMin) = landingSlot?.takeIf { it != originSlot } ?: return null
|
||||
val event = source?.event ?: return null
|
||||
val edge = resize?.edge
|
||||
val edge = resizeEdge
|
||||
if (edge != null) {
|
||||
val drop = resizedDrop(event, edge, date, startMin, spanMin, zone)
|
||||
return event.copy(start = drop.start, end = drop.end)
|
||||
@@ -828,7 +850,7 @@ class TimelineDragController {
|
||||
fun finishResize(zone: TimeZone): TimelineResizeDrop? {
|
||||
val landed = drag
|
||||
val origin = originSlot
|
||||
val edge = resize?.edge
|
||||
val edge = resizeEdge
|
||||
cancel()
|
||||
if (landed == null || landed.slot == origin) return null
|
||||
val drop = resizedDrop(landed.event, edge, landed.date, landed.eventStartMin, landed.eventSpanMin, zone)
|
||||
@@ -840,8 +862,9 @@ class TimelineDragController {
|
||||
* Where the finger is, in minutes from the resized event's start day —
|
||||
* past 1440 in a later column, negative in an earlier one.
|
||||
*/
|
||||
private fun pointerResizeMinutes(session: ResizeSession): Float? {
|
||||
private fun pointerResizeMinutes(): Float? {
|
||||
val grid = geometry.grid?.takeIf { it.isAttached } ?: return null
|
||||
val day = resizeDay ?: return null
|
||||
val hourPx = geometry.hourPx
|
||||
val columnPx = geometry.columnWidthPx
|
||||
val days = geometry.days
|
||||
@@ -849,23 +872,22 @@ class TimelineDragController {
|
||||
val origin = grid.positionInRoot()
|
||||
val held = stickyColumn(
|
||||
position = (pointer.x - origin.x) / columnPx,
|
||||
held = session.column,
|
||||
held = resizeColumn,
|
||||
lastIndex = days.lastIndex,
|
||||
)
|
||||
session.column = held
|
||||
val dayOffset = (days[column(held)].toEpochDays() - session.day.toEpochDays()).toInt()
|
||||
session.edgeDay = dayOffset
|
||||
resizeColumn = held
|
||||
val dayOffset = (days[column(held)].toEpochDays() - day.toEpochDays()).toInt()
|
||||
resizeEdgeDay = dayOffset
|
||||
return dayOffset * MINUTES_PER_DAY + (pointer.y - origin.y) / hourPx * 60f
|
||||
}
|
||||
|
||||
private fun recomputeResize(session: ResizeSession) {
|
||||
private fun recomputeResize(edge: ResizeEdge) {
|
||||
val block = source ?: return
|
||||
val edge = session.edge
|
||||
val day = session.day
|
||||
val raw = pointerResizeMinutes(session) ?: return
|
||||
val slot = stickySnap(raw - session.grabMin, session.slot)
|
||||
session.slot = slot
|
||||
val (start, end) = resizedSpan(edge, session.startMin, session.endMin, slot.toFloat(), session.edgeDay)
|
||||
val day = resizeDay ?: return
|
||||
val raw = pointerResizeMinutes() ?: return
|
||||
val slot = stickySnap(raw - resizeGrabMin, resizeSlot)
|
||||
resizeSlot = slot
|
||||
val (start, end) = resizedSpan(edge, resizeStartMin, resizeEndMin, slot.toFloat(), resizeEdgeDay)
|
||||
val days = geometry.days
|
||||
val origin = geometry.grid?.takeIf { it.isAttached }?.positionInRoot() ?: return
|
||||
// Which column the start day sits in, possibly off either end of the view.
|
||||
@@ -924,7 +946,7 @@ class TimelineDragController {
|
||||
* no move events while auto-scroll walks the content underneath it.
|
||||
*/
|
||||
fun recompute() {
|
||||
resize?.let {
|
||||
resizeEdge?.let {
|
||||
recomputeResize(it)
|
||||
return
|
||||
}
|
||||
@@ -1027,27 +1049,6 @@ class TimelineDragController {
|
||||
}
|
||||
}
|
||||
|
||||
/** One resize gesture (#141): the edge it drags, and the edges as minutes from [day]'s midnight. */
|
||||
private class ResizeSession(
|
||||
val edge: ResizeEdge,
|
||||
/** The day the resized event starts on. */
|
||||
val day: LocalDate,
|
||||
val startMin: Int,
|
||||
val endMin: Int,
|
||||
) {
|
||||
/** How far the finger sits past the edge it grabbed, so the edge doesn't jump to it. */
|
||||
var grabMin = 0f
|
||||
|
||||
/** The column the resized edge is in, held until the finger is well into another. */
|
||||
var column: Int? = null
|
||||
|
||||
/** The slot the resized edge sits on, held against a finger trembling between two. */
|
||||
var slot: Int? = null
|
||||
|
||||
/** The day, counted from the event's start day, the finger is resizing in. */
|
||||
var edgeDay = 0
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberTimelineDragController(): TimelineDragController {
|
||||
val controller = remember { TimelineDragController() }
|
||||
|
||||
@@ -95,7 +95,7 @@ import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.DeleteEventDialog
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
|
||||
import de.jeanlucmakiola.calendula.ui.common.sendEventShare
|
||||
import de.jeanlucmakiola.calendula.ui.common.FieldCopier
|
||||
import de.jeanlucmakiola.calendula.ui.common.copyOnLongPress
|
||||
@@ -169,11 +169,14 @@ fun EventDetailScreen(
|
||||
|
||||
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
||||
// upgrade in place and continues straight into the tapped action.
|
||||
val onDeleteClick = rememberWriteAction { showDeleteDialog = true }
|
||||
val onEditClick = rememberWriteAction(action = onEdit)
|
||||
val requireWrite = rememberWriteGate()
|
||||
val onDeleteClick = { requireWrite { showDeleteDialog = true } }
|
||||
val onEditClick = { requireWrite(onEdit) }
|
||||
// Duplicate reads the loaded detail into a create form and hands it up; the
|
||||
// create still needs WRITE_CALENDAR even when the source is read-only.
|
||||
val onDuplicateClick = rememberWriteAction { viewModel.duplicateForm()?.let(onDuplicate) }
|
||||
val onDuplicateClick = {
|
||||
requireWrite { viewModel.duplicateForm()?.let(onDuplicate) }
|
||||
}
|
||||
|
||||
val deleteFailedMessage = stringResource(R.string.event_delete_failed)
|
||||
val writeDeniedMessage = stringResource(R.string.event_delete_write_denied)
|
||||
|
||||
@@ -7,7 +7,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.time.ZoneSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
@@ -32,6 +31,7 @@ import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -45,7 +45,6 @@ class EventDetailViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
private val icsExporter: IcsExporter,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
private val zoneSource: ZoneSource,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _target = MutableStateFlow<Target?>(null)
|
||||
@@ -142,7 +141,7 @@ class EventDetailViewModel @Inject constructor(
|
||||
*/
|
||||
fun duplicateForm(): EventForm? {
|
||||
val loaded = state.value as? EventDetailUiState.Success ?: return null
|
||||
return loaded.detail.toDuplicateForm(loaded.canModify, zoneSource.current())
|
||||
return loaded.detail.toDuplicateForm(loaded.canModify, TimeZone.currentSystemDefault())
|
||||
}
|
||||
|
||||
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
||||
|
||||
@@ -41,7 +41,7 @@ import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||
import kotlinx.coroutines.launch
|
||||
import de.jeanlucmakiola.calendula.ui.common.sendEventShare
|
||||
import de.jeanlucmakiola.calendula.ui.common.canWriteCalendar
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
|
||||
import de.jeanlucmakiola.calendula.ui.common.DeleteEventDialog
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
@@ -168,7 +168,6 @@ import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.withRepeatedHourZone
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||
@@ -289,6 +288,7 @@ fun EventEditScreen(
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val requireWrite = rememberWriteGate()
|
||||
// Save keeps its own launcher: its result calls straight into the ViewModel,
|
||||
// so a save waiting on the permission dialog survives a rotation.
|
||||
val savePermissionLauncher = rememberLauncherForActivityResult(
|
||||
@@ -313,13 +313,14 @@ fun EventEditScreen(
|
||||
}
|
||||
}
|
||||
// The copy opens over this form; a form with nothing to lose steps aside.
|
||||
val onDuplicateClick = rememberWriteAction {
|
||||
viewModel.duplicateForm()?.let { form ->
|
||||
onDuplicate?.invoke(form)
|
||||
if (!hasUnsavedChanges) close()
|
||||
val onDuplicateClick: () -> Unit = {
|
||||
requireWrite {
|
||||
viewModel.duplicateForm()?.let { form ->
|
||||
onDuplicate?.invoke(form)
|
||||
if (!hasUnsavedChanges) close()
|
||||
}
|
||||
}
|
||||
}
|
||||
val onDeleteClick = rememberWriteAction { showDeleteDialog = true }
|
||||
|
||||
val saveFailedMessage = stringResource(R.string.event_edit_save_failed)
|
||||
val writeDeniedMessage = stringResource(R.string.event_edit_write_denied)
|
||||
@@ -385,7 +386,7 @@ fun EventEditScreen(
|
||||
canDuplicate = onDuplicate != null,
|
||||
onDuplicate = onDuplicateClick,
|
||||
onShare = onShareClick,
|
||||
onDelete = onDeleteClick,
|
||||
onDelete = { requireWrite { showDeleteDialog = true } },
|
||||
)
|
||||
}
|
||||
Button(
|
||||
@@ -1127,7 +1128,13 @@ private fun WhenCard(
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
// A pinned event's local equivalent, shown only when pinned elsewhere.
|
||||
// The rows above edit a pinned event in *its own* zone, which is the
|
||||
// time it was set at — but that says nothing about when it lands for
|
||||
// whoever is reading it. Spell the local equivalent out rather than
|
||||
// leaving the user to do the offset arithmetic. Absent unless the
|
||||
// event is pinned somewhere other than here.
|
||||
// Keyed rather than recomputed: the form recomposes on every title
|
||||
// keystroke, and this would otherwise re-parse the zone each time.
|
||||
val pinnedAway = remember(form.timezone, form.isAllDay, deviceZone) {
|
||||
form.isPinnedAwayFrom(deviceZone)
|
||||
}
|
||||
@@ -2414,8 +2421,9 @@ private fun accessLevelSummary(level: AccessLevel): Int = when (level) {
|
||||
private fun formatTimeRange(start: Instant, end: Instant, zone: TimeZone, locale: Locale): String {
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
return remember(start, end, zone, locale, use24Hour) {
|
||||
val timeFormat = timeOfDayFormatter(use24Hour, locale)
|
||||
fun format(at: Instant) = withRepeatedHourZone(
|
||||
formatTimeOfDay(at, zone, use24Hour, locale),
|
||||
timeFormat.format(at.toLocalDateTime(zone).time.toJavaLocalTime()),
|
||||
at,
|
||||
zone,
|
||||
locale,
|
||||
|
||||
@@ -590,9 +590,6 @@ class EventEditViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** The form as the user has it now, or null when none is open. */
|
||||
fun currentForm(): EventForm? = _form.value
|
||||
|
||||
/** Forget the open form; the next [openNew]/[openForEdit] starts clean. */
|
||||
fun reset() {
|
||||
session++
|
||||
|
||||
@@ -90,10 +90,8 @@ fun ImportScreen(
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
// A single event isn't shown here — it opens the create form for review.
|
||||
// Read live: the state composed with may be the previous import's result,
|
||||
// which the load above has already replaced.
|
||||
LaunchedEffect(state) {
|
||||
(viewModel.state.value as? ImportUiState.Single)?.let { onOpenSingle(it.form); onClose() }
|
||||
(state as? ImportUiState.Single)?.let { onOpenSingle(it.form); onClose() }
|
||||
}
|
||||
|
||||
// Hoisted target calendar so the always-visible top-bar Import action can
|
||||
|
||||
@@ -78,7 +78,6 @@ import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.InputChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -842,7 +841,7 @@ internal fun monthBandHeight(rowHeight: Dp): Dp =
|
||||
|
||||
/** Whether any day in this week holds more than [lanes] lanes can seat. */
|
||||
private fun MonthWeek.overflowsAt(lanes: Int): Boolean =
|
||||
days.indices.any { col -> overflowEvents(col, lanes).isNotEmpty() }
|
||||
days.withIndex().any { (col, day) -> overflowEvents(col, day, lanes).isNotEmpty() }
|
||||
|
||||
/**
|
||||
* Row height in the continuous grid. The paged grid divides the viewport between
|
||||
@@ -1530,30 +1529,18 @@ private fun SplitExpandHandle(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(SPLIT_HANDLE_ROW_HEIGHT)
|
||||
.clickable(onClick = onToggle)
|
||||
.semantics { contentDescription = label },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(width = SPLIT_HANDLE_RIPPLE_WIDTH, height = SPLIT_HANDLE_ROW_HEIGHT)
|
||||
.clip(RoundedCornerShape(percent = 50))
|
||||
.clickable(
|
||||
interactionSource = null,
|
||||
indication = ripple(),
|
||||
onClick = onToggle,
|
||||
)
|
||||
.clearAndSetSemantics { },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(width = SPLIT_HANDLE_WIDTH, height = SPLIT_HANDLE_HEIGHT)
|
||||
// Tagged too, so it slides from the pane's seam down to the foot
|
||||
// of the grid rather than blinking out at one and in at the other.
|
||||
.morphElement(MonthMorphKey.Handle)
|
||||
.background(MaterialTheme.colorScheme.outlineVariant, CircleShape),
|
||||
)
|
||||
}
|
||||
.size(width = SPLIT_HANDLE_WIDTH, height = SPLIT_HANDLE_HEIGHT)
|
||||
// Tagged too, so it slides from the pane's seam down to the foot
|
||||
// of the grid rather than blinking out at one and in at the other.
|
||||
.morphElement(MonthMorphKey.Handle)
|
||||
.background(MaterialTheme.colorScheme.outlineVariant, CircleShape),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1586,7 +1573,6 @@ private val SPLIT_DOT_SIZE = 5.dp
|
||||
private val SPLIT_HANDLE_WIDTH = 32.dp
|
||||
private val SPLIT_HANDLE_HEIGHT = 4.dp
|
||||
private val SPLIT_HANDLE_ROW_HEIGHT = 24.dp
|
||||
private val SPLIT_HANDLE_RIPPLE_WIDTH = SPLIT_HANDLE_WIDTH + 24.dp
|
||||
|
||||
/** The pane below the split grid fades in once the grid's morph has mostly landed. */
|
||||
private const val SPLIT_PANE_FADE_IN_DELAY_MILLIS = 300
|
||||
@@ -1641,11 +1627,11 @@ internal fun SplitMonthGrid(
|
||||
val inMonth = day.month == month.month && day.year == month.year
|
||||
// Seated by lane rather than gathered by colour, so each dot
|
||||
// is the event the expanded grid draws in that same lane.
|
||||
val seated = week.laneEvents(col, SPLIT_DOT_LANES)
|
||||
val seated = week.laneEvents(col, day, SPLIT_DOT_LANES)
|
||||
SplitDayCell(
|
||||
date = day,
|
||||
events = seated,
|
||||
hidden = week.overflowEvents(col, SPLIT_DOT_LANES),
|
||||
hidden = week.overflowEvents(col, day, SPLIT_DOT_LANES),
|
||||
isToday = day == state.today,
|
||||
// A page marks only the days its own month owns. Paging
|
||||
// moves the selection before this month's replacement
|
||||
@@ -1730,7 +1716,6 @@ private fun SplitDayCell(
|
||||
modifier = modifier
|
||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||
.todayRipple(relativeToToday, rippleProgress)
|
||||
.clip(CELL_SHAPE)
|
||||
.selectable(selected = isSelected, onClick = onClick),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
@@ -2113,8 +2098,8 @@ private fun MonthWeekRow(
|
||||
val ripple = rememberTodayRipple(active = todayCol != null)
|
||||
val overflowRow = rememberOverflowRowHeight()
|
||||
val laneCap = week.laneCapFor(rowHeight, overflowRow)
|
||||
// Once per row rather than per frame: a drag recomposes every frame.
|
||||
val seatings = remember(week, laneCap) { week.days.indices.map { week.seating(it, laneCap) } }
|
||||
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
|
||||
val shownLanes = laneCount.coerceAtMost(laneCap)
|
||||
val morphing = morphInFlight()
|
||||
// Every chip's start time for this row at once, and only when the row's own
|
||||
// inputs change: formatting is a parsed pattern per call, and the dim cutoff
|
||||
@@ -2298,7 +2283,7 @@ private fun MonthWeekRow(
|
||||
.then(if (morphing || dragging) Modifier else Modifier.clipToBounds()),
|
||||
) {
|
||||
// Spanning bars on their shared lanes.
|
||||
week.spans.filter { it.lane < laneCap }.forEach { span ->
|
||||
week.spans.filter { it.lane < shownLanes }.forEach { span ->
|
||||
val cols = span.endCol - span.startCol + 1
|
||||
MonthBar(
|
||||
event = span.event,
|
||||
@@ -2370,13 +2355,19 @@ private fun MonthWeekRow(
|
||||
)
|
||||
}
|
||||
}
|
||||
// Single-day timed pills + overflow, per column, in the lanes
|
||||
// [seating] gave them.
|
||||
// Single-day timed pills + overflow, per column. Pills fill the
|
||||
// lane slots no bar occupies on THIS day (top-most first), so a
|
||||
// bar-free day isn't pushed down by a multi-day event that only
|
||||
// sits on other days of the week.
|
||||
week.days.forEachIndexed { col, d ->
|
||||
val seating = seatings[col]
|
||||
seating.seated.forEach { seat ->
|
||||
if (seat.bar) return@forEach
|
||||
val ev = seat.event
|
||||
val timed = week.timedByDay[d].orEmpty()
|
||||
val occupied = week.spans
|
||||
.filter { it.lane < shownLanes && col in it.startCol..it.endCol }
|
||||
.map { it.lane }
|
||||
.toSet()
|
||||
val freeSlots = (0 until laneCap).filter { it !in occupied }
|
||||
val pillsShown = timed.take(freeSlots.size)
|
||||
pillsShown.forEachIndexed { i, ev ->
|
||||
MonthBar(
|
||||
event = ev,
|
||||
dark = dark,
|
||||
@@ -2388,7 +2379,7 @@ private fun MonthWeekRow(
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
x = colW * col,
|
||||
y = EVENT_ROW_HEIGHT * seat.lane,
|
||||
y = EVENT_ROW_HEIGHT * freeSlots[i],
|
||||
)
|
||||
.morphBounds(MonthMorphKey.Event(d, ev.instanceId))
|
||||
.then(
|
||||
@@ -2404,10 +2395,18 @@ private fun MonthWeekRow(
|
||||
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
if (seating.hidden.isNotEmpty()) {
|
||||
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
|
||||
if (hidden > 0) {
|
||||
val hiddenEvents = buildList {
|
||||
week.spans
|
||||
.filter { it.lane >= shownLanes && col in it.startCol..it.endCol }
|
||||
.forEach { add(it.event) }
|
||||
addAll(timed.drop(pillsShown.size))
|
||||
}
|
||||
OverflowDots(
|
||||
events = seating.hidden,
|
||||
events = hiddenEvents,
|
||||
day = d,
|
||||
total = hidden,
|
||||
dark = dark,
|
||||
rowHeight = overflowRow,
|
||||
modifier = Modifier
|
||||
@@ -2631,12 +2630,11 @@ private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier
|
||||
val onOpenWeek = LocalOpenWeek.current
|
||||
Box(
|
||||
modifier = modifier
|
||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||
.clip(CELL_SHAPE)
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer)
|
||||
.then(if (onOpenWeek != null) Modifier.clickable(onClickLabel = openLabel) {
|
||||
onOpenWeek(weekStart)
|
||||
} else Modifier)
|
||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer, CELL_SHAPE)
|
||||
.semantics { contentDescription = "$label $weekNumber" },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
@@ -2788,6 +2786,7 @@ private fun MonthBar(
|
||||
private fun OverflowDots(
|
||||
events: List<EventInstance>,
|
||||
day: LocalDate,
|
||||
total: Int,
|
||||
dark: Boolean,
|
||||
rowHeight: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -2809,7 +2808,7 @@ private fun OverflowDots(
|
||||
.background(eventAccent(argb, dark, soften), CircleShape),
|
||||
)
|
||||
}
|
||||
val extra = events.size - dots.size
|
||||
val extra = total - dots.size
|
||||
if (extra > 0) {
|
||||
Text(
|
||||
text = "+$extra",
|
||||
|
||||
@@ -39,55 +39,33 @@ data class MonthWeek(
|
||||
val countByDay: Map<LocalDate, Int>,
|
||||
)
|
||||
|
||||
/** One event seated in [lane] of a day; [bar] is a spanning bar rather than that day's own pill. */
|
||||
data class Seat(val lane: Int, val event: EventInstance, val bar: Boolean)
|
||||
|
||||
/**
|
||||
* How one day of a week row is seated with [laneCap] lanes: what sits in each
|
||||
* lane, top-most first with empty lanes left out, and what had no lane left.
|
||||
*/
|
||||
data class DaySeating(val seated: List<Seat>, val hidden: List<EventInstance>)
|
||||
|
||||
/**
|
||||
* The seating of column [col] with [laneCap] lanes — the one every reader of a
|
||||
* row builds on: the bars and pills the paged grid draws, the split style's dots
|
||||
* ([laneEvents]), the hit test ([chipAt]) and the overflow ([overflowEvents]).
|
||||
* The events occupying each lane of [day] — column [col] of this week — in lane
|
||||
* order, capped at [laneCap] lanes.
|
||||
*
|
||||
* Bars keep the lane the row layout gave them, and the day's timed events fill
|
||||
* whatever slots are left, top-most first, so a bar-free day isn't pushed down by
|
||||
* a multi-day event that only sits on other days of the week. Overflow is the
|
||||
* exact complement, bars before pills.
|
||||
*/
|
||||
fun MonthWeek.seating(col: Int, laneCap: Int): DaySeating {
|
||||
val covering = spans.filter { col in it.startCol..it.endCol }
|
||||
val byLane = arrayOfNulls<Seat>(laneCap.coerceAtLeast(0))
|
||||
covering.forEach { span ->
|
||||
if (span.lane < laneCap) byLane[span.lane] = Seat(span.lane, span.event, bar = true)
|
||||
}
|
||||
val timed = timedByDay[days[col]].orEmpty()
|
||||
var pills = 0
|
||||
for (lane in byLane.indices) {
|
||||
if (byLane[lane] == null && pills < timed.size) byLane[lane] = Seat(lane, timed[pills++], bar = false)
|
||||
}
|
||||
return DaySeating(
|
||||
seated = byLane.filterNotNull(),
|
||||
hidden = covering.filter { it.lane >= laneCap }.map { it.event } + timed.drop(pills),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The events occupying each lane of column [col], in lane order, capped at
|
||||
* [laneCap] lanes — the same [seating] the paged grid draws a week with. Reading
|
||||
* it here means the split style's dots and the paged style's bars describe a day
|
||||
* in the *same order*, so dot _i_ and lane _i_ are the same event and one can
|
||||
* morph into the other (#53).
|
||||
* This is the same seating [spans]/[timedByDay] get when the paged grid draws a
|
||||
* week: bars keep the lane the row layout gave them, and the day's timed events
|
||||
* fill whatever slots are left, top-most first. Reading it here means the split
|
||||
* style's dots and the paged style's bars describe a day in the *same order*, so
|
||||
* dot _i_ and lane _i_ are the same event and one can morph into the other (#53).
|
||||
*
|
||||
* Deriving the dots independently — by distinct colour, as they first were —
|
||||
* left dot _i_ standing for no particular event, and quietly merged two events
|
||||
* that shared a calendar into a single dot.
|
||||
*/
|
||||
fun MonthWeek.laneEvents(col: Int, laneCap: Int): List<EventInstance> =
|
||||
seating(col, laneCap).seated.map { it.event }
|
||||
fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
|
||||
val byLane = arrayOfNulls<EventInstance>(laneCap)
|
||||
spans.forEach { span ->
|
||||
if (span.lane < laneCap && col in span.startCol..span.endCol) {
|
||||
byLane[span.lane] = span.event
|
||||
}
|
||||
}
|
||||
val free = (0 until laneCap).filter { byLane[it] == null }
|
||||
timedByDay[day].orEmpty().take(free.size).forEachIndexed { i, event ->
|
||||
byLane[free[i]] = event
|
||||
}
|
||||
return byLane.filterNotNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* The event drawn in lane [lane] of column [col], or null for an empty slot.
|
||||
@@ -96,7 +74,14 @@ fun MonthWeek.laneEvents(col: Int, laneCap: Int): List<EventInstance> =
|
||||
*/
|
||||
fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
|
||||
if (col !in days.indices || lane !in 0 until laneCap) return null
|
||||
return seating(col, laneCap).seated.firstOrNull { it.lane == lane }?.event
|
||||
spanAt(col, lane)?.let { return it.event }
|
||||
val occupied = spans
|
||||
.filter { it.lane < laneCap && col in it.startCol..it.endCol }
|
||||
.map { it.lane }
|
||||
.toSet()
|
||||
val free = (0 until laneCap).filter { it !in occupied }
|
||||
val index = free.indexOf(lane).takeIf { it >= 0 } ?: return null
|
||||
return timedByDay[days[col]].orEmpty().take(free.size).getOrNull(index)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,11 +98,19 @@ fun MonthWeek.spanAt(col: Int, lane: Int): MonthSpan? =
|
||||
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
|
||||
|
||||
/**
|
||||
* The events in column [col] that [laneEvents] had no lane left for — its exact
|
||||
* The events on [day] that [laneEvents] had no lane left for — its exact
|
||||
* complement, in the same bars-then-pills order. Returned as events rather than
|
||||
* a count because dimming is a per-event question (#79).
|
||||
*/
|
||||
fun MonthWeek.overflowEvents(col: Int, laneCap: Int): List<EventInstance> = seating(col, laneCap).hidden
|
||||
fun MonthWeek.overflowEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
|
||||
val seatedLanes = spans.count { it.lane < laneCap && col in it.startCol..it.endCol }
|
||||
return buildList {
|
||||
spans.forEach { span ->
|
||||
if (span.lane >= laneCap && col in span.startCol..span.endCol) add(span.event)
|
||||
}
|
||||
addAll(timedByDay[day].orEmpty().drop(laneCap - seatedLanes))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* State for the continuous style (#38): a vertical stream of *self-contained*
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package de.jeanlucmakiola.calendula.ui.search
|
||||
|
||||
import android.Manifest
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.background
|
||||
@@ -56,6 +59,7 @@ import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -76,11 +80,11 @@ 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.floret.android.hasPermission
|
||||
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
@@ -129,6 +133,7 @@ fun SearchScreen(
|
||||
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
val context = LocalContext.current
|
||||
val inSelection = selection.isNotEmpty()
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -150,8 +155,24 @@ fun SearchScreen(
|
||||
keyboard?.show()
|
||||
}
|
||||
|
||||
// A v1.0 install holds only READ_CALENDAR; granting continues into the held delete.
|
||||
val onDeleteClick = rememberWriteAction { showDeleteDialog = true }
|
||||
// Same in-place WRITE_CALENDAR upgrade the detail screen does: a v1.0 install
|
||||
// holds only READ_CALENDAR, and granting continues into the held delete.
|
||||
var pendingWrite by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||
val writePermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (granted) pendingWrite?.invoke()
|
||||
pendingWrite = null
|
||||
}
|
||||
val requireWrite: (() -> Unit) -> Unit = { action ->
|
||||
val granted = context.hasPermission(Manifest.permission.WRITE_CALENDAR)
|
||||
if (granted) {
|
||||
action()
|
||||
} else {
|
||||
pendingWrite = action
|
||||
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||
}
|
||||
}
|
||||
|
||||
// Back leaves selection mode before it leaves the screen, and without the
|
||||
// predictive-back scale-out — that would read as search closing and snapping
|
||||
@@ -172,7 +193,7 @@ fun SearchScreen(
|
||||
onBack = onBack,
|
||||
onCloseSelection = viewModel::clearSelection,
|
||||
onSelectAll = viewModel::selectAll,
|
||||
onDelete = onDeleteClick,
|
||||
onDelete = { requireWrite { showDeleteDialog = true } },
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
|
||||
@@ -7,7 +7,6 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventSearch
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.ui.common.deleteEvent
|
||||
import de.jeanlucmakiola.calendula.domain.SearchResults
|
||||
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||
@@ -197,11 +196,15 @@ class SearchViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private suspend fun deleteOne(event: EventInstance, scope: RecurringWriteScope) {
|
||||
repository.deleteEvent(
|
||||
eventId = event.eventId,
|
||||
beginMillis = event.start.toEpochMilliseconds(),
|
||||
scope = if (event.isRecurring) scope else RecurringWriteScope.AllEvents,
|
||||
)
|
||||
val begin = event.start.toEpochMilliseconds()
|
||||
when {
|
||||
!event.isRecurring -> repository.deleteEvent(event.eventId)
|
||||
scope == RecurringWriteScope.ThisEvent ->
|
||||
repository.deleteOccurrence(event.eventId, begin)
|
||||
scope == RecurringWriteScope.ThisAndFollowing ->
|
||||
repository.deleteEventFromOccurrence(event.eventId, begin)
|
||||
else -> repository.deleteEvent(event.eventId)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readOnlyCalendarIds(): Set<Long> =
|
||||
|
||||
@@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Autorenew
|
||||
import androidx.compose.material.icons.filled.Backup
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
import androidx.compose.material.icons.filled.Cake
|
||||
@@ -24,7 +23,6 @@ import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Gavel
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.filled.LocalCafe
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.PrivacyTip
|
||||
@@ -58,7 +56,6 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.licences.LicencesScreen
|
||||
import de.jeanlucmakiola.floret.components.AboutCard
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||
@@ -155,7 +152,7 @@ fun SettingsScreen(
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
WidgetsScreen(onBack = { section = null }, visible = section == SettingsSection.Widgets)
|
||||
WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null }, visible = section == SettingsSection.Widgets)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = section == SettingsSection.Licences,
|
||||
@@ -363,46 +360,17 @@ private fun AboutCard() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* "Support development", as the closing row of the About card's group. Opens a
|
||||
* picker of the donation platforms.
|
||||
*/
|
||||
/** "Support development", as the closing row of the About card's group. */
|
||||
@Composable
|
||||
private fun SupportRow() {
|
||||
val context = LocalContext.current
|
||||
var showPicker by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val url = stringResource(R.string.about_support_url)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_about_support),
|
||||
position = Position.Bottom,
|
||||
leading = { CategoryIcon(Icons.Default.Favorite, ChipAccent.Primary) },
|
||||
onClick = { showPicker = true },
|
||||
onClick = { openUrl(context, url) },
|
||||
)
|
||||
|
||||
if (showPicker) {
|
||||
val kofiUrl = stringResource(R.string.about_support_kofi_url)
|
||||
val liberapayUrl = stringResource(R.string.about_support_liberapay_url)
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.settings_about_support),
|
||||
onDismiss = { showPicker = false },
|
||||
predictiveBack = true,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_support_kofi),
|
||||
summary = stringResource(R.string.settings_support_kofi_hint),
|
||||
position = Position.Top,
|
||||
leading = { CategoryIcon(Icons.Default.LocalCafe, ChipAccent.Primary) },
|
||||
onClick = { openUrl(context, kofiUrl) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_support_liberapay),
|
||||
summary = stringResource(R.string.settings_support_liberapay_hint),
|
||||
position = Position.Bottom,
|
||||
leading = { CategoryIcon(Icons.Default.Autorenew, ChipAccent.Primary) },
|
||||
onClick = { openUrl(context, liberapayUrl) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,6 +16,7 @@ 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
|
||||
@@ -50,10 +51,14 @@ data class SettingsUiState(
|
||||
val tapOpensEditor: Boolean = false,
|
||||
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
||||
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
||||
/** Whether the agenda screen always anchors today at the top (#35); widgets have their own (#292). */
|
||||
/** 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). */
|
||||
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). */
|
||||
|
||||
@@ -3,6 +3,9 @@ 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
|
||||
@@ -39,12 +42,20 @@ 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
|
||||
@@ -56,6 +67,8 @@ 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
|
||||
|
||||
@@ -105,8 +118,10 @@ class SettingsViewModel @Inject constructor(
|
||||
dragToReschedule = this[SettingsKeys.dragToReschedule],
|
||||
tapOpensEditor = this[SettingsKeys.tapOpensEditor],
|
||||
agendaScreenRange = this[SettingsKeys.agendaScreenRange],
|
||||
agendaWidgetRange = this[SettingsKeys.agendaWidgetRange],
|
||||
agendaShowToday = this[SettingsKeys.agendaShowToday],
|
||||
agendaShowRangeBar = this[SettingsKeys.agendaShowRangeBar],
|
||||
widgetSize = this[SettingsKeys.widgetSize],
|
||||
defaultView = this[SettingsKeys.defaultView],
|
||||
quickSwitchConfig = this[SettingsKeys.quickSwitchConfig],
|
||||
monthViewStyle = this[SettingsKeys.monthViewStyle],
|
||||
@@ -166,6 +181,10 @@ class SettingsViewModel @Inject constructor(
|
||||
private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
val fontImportFailed: SharedFlow<Unit> = _fontImportFailed
|
||||
|
||||
// Serialises widget redraws: concurrent updateAll calls coalesce around a
|
||||
// stale read and can leave the widget on an intermediate value.
|
||||
private val widgetRefreshMutex = Mutex()
|
||||
|
||||
/** Contact special-dates sub-page (issue #15). */
|
||||
val specialDatesState: StateFlow<SpecialDatesUiState> = prefs.data.map { stored ->
|
||||
val templates = stored.specialDatesTitleTemplates
|
||||
@@ -248,7 +267,16 @@ class SettingsViewModel @Inject constructor(
|
||||
|
||||
fun setDynamicColor(enabled: Boolean) = save { setDynamicColor(enabled) }
|
||||
|
||||
fun setSoftenColors(enabled: Boolean) = save { setSoftenCalendarColors(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 setFont(role: FontRole, token: String) = save {
|
||||
when (role) {
|
||||
@@ -276,13 +304,68 @@ class SettingsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun setWeekStart(pref: WeekStartPref) = save { setWeekStart(pref) }
|
||||
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 setAgendaScreenRange(range: AgendaRange) = save { setAgendaScreenRange(range) }
|
||||
|
||||
fun setAgendaWidgetRange(range: AgendaRange) {
|
||||
viewModelScope.launch {
|
||||
prefs.setAgendaWidgetRange(range)
|
||||
widgetRefreshMutex.withLock {
|
||||
// Push into each instance's Glance state and recompose:
|
||||
// updateAll alone won't re-run provideGlance's data preamble.
|
||||
val manager = GlanceAppWidgetManager(appContext)
|
||||
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
||||
updateAppWidgetState(appContext, id) { it[AGENDA_RANGE_KEY] = range.storageValue() }
|
||||
}
|
||||
AgendaWidget().updateAll(appContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAgendaShowRangeBar(enabled: Boolean) = save { setAgendaShowRangeBar(enabled) }
|
||||
|
||||
fun setAgendaShowToday(enabled: Boolean) = save { setAgendaShowToday(enabled) }
|
||||
/**
|
||||
* Set the size the agenda widget draws its text at (#51), via the same
|
||||
* Glance-state path as [setAgendaWidgetRange]. Agenda-only: the month widget
|
||||
* sizes itself from the space it is given (#103).
|
||||
*/
|
||||
fun setWidgetSize(size: WidgetSize) {
|
||||
viewModelScope.launch {
|
||||
prefs.setWidgetSize(size)
|
||||
widgetRefreshMutex.withLock {
|
||||
val manager = GlanceAppWidgetManager(appContext)
|
||||
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
||||
updateAppWidgetState(appContext, id) { it[AGENDA_SIZE_KEY] = size.name }
|
||||
}
|
||||
AgendaWidget().updateAll(appContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAgendaShowToday(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setAgendaShowToday(enabled)
|
||||
// Same Glance-state path as setPastEventDisplay.
|
||||
widgetRefreshMutex.withLock {
|
||||
val manager = GlanceAppWidgetManager(appContext)
|
||||
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
||||
updateAppWidgetState(appContext, id) { it[AGENDA_SHOW_TODAY_STATE_KEY] = enabled }
|
||||
}
|
||||
AgendaWidget().updateAll(appContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setTimeFormat(pref: TimeFormatPref) = save { setTimeFormat(pref) }
|
||||
|
||||
@@ -313,7 +396,19 @@ class SettingsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun setPastEventDisplay(mode: PastEventDisplay) = save { setPastEventDisplay(mode) }
|
||||
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 setDimCompletedEvents(enabled: Boolean) = save { setDimCompletedEvents(enabled) }
|
||||
|
||||
|
||||
@@ -10,9 +10,7 @@ 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.mutableIntStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -20,46 +18,56 @@ 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.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
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Widgets & tiles (#69): the placed home-screen widgets, each opening its own
|
||||
* config screen (#292), and the Quick Settings tile shortcut.
|
||||
* 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].
|
||||
*/
|
||||
@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(
|
||||
// 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,
|
||||
),
|
||||
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 },
|
||||
)
|
||||
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.
|
||||
@@ -68,44 +76,30 @@ internal fun WidgetsScreen(
|
||||
QuickSettingsTileRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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 {}
|
||||
}
|
||||
val widgets by produceState<List<PlacedWidget>?>(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,
|
||||
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 },
|
||||
)
|
||||
return
|
||||
}
|
||||
val kindCounts = placed.groupingBy { it.kind }.eachCount()
|
||||
val kindSeen = mutableMapOf<WidgetKind, Int>()
|
||||
placed.forEachIndexed { index, widget ->
|
||||
val number = kindSeen.merge(widget.kind, 1, Int::plus)!!
|
||||
val label = stringResource(widget.kind.label)
|
||||
GroupedRow(
|
||||
title = if (kindCounts.getValue(widget.kind) > 1) stringResource(R.string.widget_numbered, label, number) else label,
|
||||
summary = stringResource(
|
||||
R.string.widget_appearance_summary,
|
||||
backgroundLabel(widget.appearance.background),
|
||||
themeLabel(widget.appearance.theme),
|
||||
),
|
||||
position = positionOf(index, placed.size),
|
||||
onClick = { context.startActivity(WidgetConfigActivity.intent(context, widget.appWidgetId)) },
|
||||
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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -137,3 +131,24 @@ 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
|
||||
},
|
||||
)
|
||||
|
||||
@@ -7,8 +7,8 @@ import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
|
||||
/**
|
||||
* A synchronous copy of the theme and dynamic-colour choices, for the window
|
||||
* backdrop and the first Compose frame. The real setting lives in DataStore, which only
|
||||
* A synchronous copy of the theme choice, for the window backdrop drawn before
|
||||
* the first Compose frame. The real setting lives in DataStore, which only
|
||||
* answers asynchronously — too late for onCreate, so a Light, Dark or Black
|
||||
* override flashed the night-qualifier backdrop on a cold start or a
|
||||
* recreation (#181). MainActivity mirrors every stored value here and reads it
|
||||
@@ -18,7 +18,6 @@ internal object WindowThemeCache {
|
||||
|
||||
private const val FILE = "window_theme"
|
||||
private const val KEY_THEME_MODE = "theme_mode"
|
||||
private const val KEY_DYNAMIC_COLOR = "dynamic_color"
|
||||
|
||||
/** The last mirrored theme choice, or null before the first mirror. */
|
||||
fun read(context: Context): ThemeMode? =
|
||||
@@ -26,23 +25,10 @@ internal object WindowThemeCache {
|
||||
.getString(KEY_THEME_MODE, null)
|
||||
?.let { name -> ThemeMode.entries.firstOrNull { it.name == name } }
|
||||
|
||||
/** The last mirrored dynamic-colour choice, or null before the first mirror. */
|
||||
fun readDynamicColor(context: Context): Boolean? {
|
||||
fun write(context: Context, mode: ThemeMode) {
|
||||
val prefs = context.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
||||
return if (prefs.contains(KEY_DYNAMIC_COLOR)) prefs.getBoolean(KEY_DYNAMIC_COLOR, true) else null
|
||||
}
|
||||
|
||||
fun write(context: Context, mode: ThemeMode, dynamicColor: Boolean) {
|
||||
val prefs = context.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
||||
if (prefs.getString(KEY_THEME_MODE, null) == mode.name &&
|
||||
prefs.contains(KEY_DYNAMIC_COLOR) && prefs.getBoolean(KEY_DYNAMIC_COLOR, true) == dynamicColor
|
||||
) {
|
||||
return
|
||||
}
|
||||
prefs.edit {
|
||||
putString(KEY_THEME_MODE, mode.name)
|
||||
putBoolean(KEY_DYNAMIC_COLOR, dynamicColor)
|
||||
}
|
||||
if (prefs.getString(KEY_THEME_MODE, null) == mode.name) return
|
||||
prefs.edit { putString(KEY_THEME_MODE, mode.name) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,63 +7,100 @@ import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.daysUntil
|
||||
import kotlinx.datetime.plus
|
||||
|
||||
/** Pages the week pager spans — a little under four centuries. */
|
||||
/** Pages the week pager spans — a little under four centuries, centred on [PAGE_EPOCH]. */
|
||||
internal const val WEEK_PAGE_COUNT: Int = 20_000
|
||||
|
||||
private const val EPOCH_PAGE = WEEK_PAGE_COUNT / 2
|
||||
|
||||
/** The week at [EPOCH_PAGE]; any date works, it only has to stay fixed. */
|
||||
private val PAGE_EPOCH = LocalDate(2000, 1, 3)
|
||||
|
||||
/** First day of the week shown on pager [page], with weeks starting on [firstDay]. */
|
||||
internal fun weekStartForPage(page: Int, firstDay: DayOfWeek): LocalDate =
|
||||
PAGE_EPOCH.startOfWeek(firstDay).plus((page - EPOCH_PAGE) * 7, DateTimeUnit.DAY)
|
||||
|
||||
/** The pager page whose week contains [date], with weeks starting on [firstDay]. */
|
||||
internal fun weekPageFor(date: LocalDate, firstDay: DayOfWeek): Int {
|
||||
val days = PAGE_EPOCH.startOfWeek(firstDay).daysUntil(date.startOfWeek(firstDay))
|
||||
return (EPOCH_PAGE + days / 7).coerceIn(0, WEEK_PAGE_COUNT - 1)
|
||||
}
|
||||
|
||||
/** How a timeline pager cuts the calendar into pages of [dayCount] days. */
|
||||
sealed interface PageGrid {
|
||||
val dayCount: Int
|
||||
val pageCount: Int
|
||||
|
||||
/** First day shown on pager [page]. */
|
||||
fun startForPage(page: Int): LocalDate
|
||||
|
||||
/** The pager page showing [date]. */
|
||||
fun pageFor(date: LocalDate): Int
|
||||
}
|
||||
|
||||
/** Whole weeks, starting on [firstDay]. */
|
||||
data class WeekGrid(val firstDay: DayOfWeek) : PageGrid {
|
||||
override val dayCount: Int get() = 7
|
||||
override val pageCount: Int get() = WEEK_PAGE_COUNT
|
||||
override fun startForPage(page: Int): LocalDate = weekStartForPage(page, firstDay)
|
||||
override fun pageFor(date: LocalDate): Int = weekPageFor(date, firstDay)
|
||||
}
|
||||
|
||||
/** 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
|
||||
private const val DAYS_EPOCH_PAGE = DAYS_PAGE_COUNT / 2
|
||||
|
||||
/** A fixed day every grid's pages are counted from, at its middle page. */
|
||||
/** A fixed day the multi-day 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. */
|
||||
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.
|
||||
* Runs of [dayCount] days (#193), offset [phase] days from a fixed epoch so any
|
||||
* date can start a page. The phase 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(override val dayCount: Int, val phase: Int) : PageGrid {
|
||||
override val pageCount: Int get() = DAYS_PAGE_COUNT
|
||||
|
||||
private val epochPage: Int get() = pageCount / 2
|
||||
override fun startForPage(page: Int): LocalDate =
|
||||
DAYS_EPOCH.plus(phase + (page - DAYS_EPOCH_PAGE) * dayCount, DateTimeUnit.DAY)
|
||||
|
||||
/** First day shown on pager [page]. */
|
||||
fun startForPage(page: Int): LocalDate =
|
||||
DAYS_EPOCH.plus(phase + (page - epochPage) * dayCount, DateTimeUnit.DAY)
|
||||
|
||||
/** The pager page showing [date]. */
|
||||
fun pageFor(date: LocalDate): Int =
|
||||
(epochPage + Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, dayCount))
|
||||
.coerceIn(0, pageCount - 1)
|
||||
override fun pageFor(date: LocalDate): Int =
|
||||
(DAYS_EPOCH_PAGE + Math.floorDiv(DAYS_EPOCH.daysUntil(date) - phase, dayCount))
|
||||
.coerceIn(0, DAYS_PAGE_COUNT - 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)
|
||||
|
||||
/** 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. */
|
||||
fun startingAt(date: LocalDate, dayCount: Int): DaysGrid =
|
||||
DaysGrid(dayCount, Math.floorMod(DAYS_EPOCH.daysUntil(date), dayCount))
|
||||
}
|
||||
}
|
||||
|
||||
/** Pages the day pager spans — a little under four centuries, centred on [DAY_EPOCH]. */
|
||||
internal const val DAY_PAGE_COUNT: Int = 140_000
|
||||
|
||||
private const val DAY_EPOCH_PAGE = DAY_PAGE_COUNT / 2
|
||||
|
||||
/** The day at [DAY_EPOCH_PAGE]; any date works, it only has to stay fixed. */
|
||||
private val DAY_EPOCH = LocalDate(2000, 1, 1)
|
||||
|
||||
/** Single days: every date starts a page of its own. */
|
||||
data object DayGrid : PageGrid {
|
||||
override val dayCount: Int get() = 1
|
||||
override val pageCount: Int get() = DAY_PAGE_COUNT
|
||||
|
||||
override fun startForPage(page: Int): LocalDate =
|
||||
DAY_EPOCH.plus(page - DAY_EPOCH_PAGE, DateTimeUnit.DAY)
|
||||
|
||||
override fun pageFor(date: LocalDate): Int =
|
||||
(DAY_EPOCH_PAGE + DAY_EPOCH.daysUntil(date)).coerceIn(0, DAY_PAGE_COUNT - 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the focused date goes when the pager settles on [page] (#184): kept if
|
||||
* the page still shows it, otherwise moved by the pages travelled, so it keeps
|
||||
* its place in the page — the same weekday a week on.
|
||||
*/
|
||||
internal fun DaysGrid.focusOnPage(focus: LocalDate, page: Int): LocalDate {
|
||||
internal fun PageGrid.focusOnPage(focus: LocalDate, page: Int): LocalDate {
|
||||
val start = startForPage(page)
|
||||
val end = start.plus(dayCount - 1, DateTimeUnit.DAY)
|
||||
if (focus in start..end) return focus
|
||||
|
||||
@@ -70,7 +70,7 @@ import de.jeanlucmakiola.calendula.ui.common.draftEvent
|
||||
import de.jeanlucmakiola.calendula.ui.common.draftStartMin
|
||||
import de.jeanlucmakiola.calendula.ui.common.isDraft
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberWriteGate
|
||||
import de.jeanlucmakiola.calendula.ui.common.OutOfViewEdges
|
||||
import de.jeanlucmakiola.calendula.ui.common.wallClockInstant
|
||||
import de.jeanlucmakiola.calendula.ui.common.PickUp
|
||||
@@ -464,12 +464,11 @@ private fun PagedTimelineScreen(
|
||||
|
||||
// Straight to the pager: a tap mid-fling must still land, and the anchor
|
||||
// hasn't caught up with the fling yet to tell the difference. A date that
|
||||
// would open mid-page on this grid, in a view that frames its pages from the
|
||||
// date (multi-day), re-frames the pages around it instead.
|
||||
// would open mid-page on this grid re-frames the pages around it instead.
|
||||
val jumpToDate: (LocalDate) -> Unit = { target ->
|
||||
focus?.date = target
|
||||
val page = grid.pageFor(target)
|
||||
if (grid.startForPage(page) != target && viewModel.rule.value(target) != grid) {
|
||||
if (grid is DaysGrid && grid.startForPage(page) != target) {
|
||||
viewModel.jumpTo(target)
|
||||
} else {
|
||||
scope.launch { pagerState.jumpToPage(page, reduceMotion, pageSpec) }
|
||||
@@ -532,7 +531,7 @@ private fun WeekContent(
|
||||
state: WeekUiState,
|
||||
today: LocalDate,
|
||||
pagerState: PagerState,
|
||||
grid: DaysGrid,
|
||||
grid: PageGrid,
|
||||
kind: TimelineKind,
|
||||
morph: TimelineMorph?,
|
||||
handoffStrip: Dp?,
|
||||
@@ -638,15 +637,10 @@ private fun WeekContent(
|
||||
delay(SELECTION_ORPHAN_GRACE_MILLIS)
|
||||
dragController.deselect()
|
||||
}
|
||||
var pendingDraft by remember { mutableStateOf<EventInstance?>(null) }
|
||||
val createPendingDraft = rememberWriteAction {
|
||||
pendingDraft?.let(createDraft)
|
||||
pendingDraft = null
|
||||
}
|
||||
val requireWrite = rememberWriteGate()
|
||||
val openEvent: (EventInstance) -> Unit = { event ->
|
||||
if (event.isDraft) {
|
||||
pendingDraft = event
|
||||
createPendingDraft()
|
||||
requireWrite { createDraft(event) }
|
||||
} else {
|
||||
dragController.deselect()
|
||||
onEventClick(event)
|
||||
@@ -702,7 +696,7 @@ private fun WeekContent(
|
||||
@Composable
|
||||
private fun WeekPager(
|
||||
pagerState: PagerState,
|
||||
grid: DaysGrid,
|
||||
grid: PageGrid,
|
||||
kind: TimelineKind,
|
||||
span: (DaySpan) -> StateFlow<WeekUiState>,
|
||||
today: LocalDate,
|
||||
|
||||
@@ -62,10 +62,10 @@ private const val SPAN_CACHE_DAYS = 28
|
||||
data class DaySpan(val start: LocalDate, val count: Int)
|
||||
|
||||
/** How the pager cuts the calendar, and the page the anchor sits on. */
|
||||
data class PagerPosition(val grid: DaysGrid, val page: Int)
|
||||
data class PagerPosition(val grid: PageGrid, val page: Int)
|
||||
|
||||
/** Where [anchor] sits under [rule]. */
|
||||
fun positionOf(anchor: LocalDate, rule: (LocalDate) -> DaysGrid): PagerPosition =
|
||||
fun positionOf(anchor: LocalDate, rule: (LocalDate) -> PageGrid): PagerPosition =
|
||||
rule(anchor).let { PagerPosition(it, it.pageFor(anchor)) }
|
||||
|
||||
/**
|
||||
@@ -84,8 +84,8 @@ abstract class PagedTimelineViewModel(
|
||||
todaySource: TodaySource,
|
||||
zoneSource: ZoneSource,
|
||||
private val io: CoroutineDispatcher,
|
||||
gridRule: Flow<(LocalDate) -> DaysGrid>,
|
||||
initialRule: (LocalDate) -> DaysGrid,
|
||||
gridRule: Flow<(LocalDate) -> PageGrid>,
|
||||
initialRule: (LocalDate) -> PageGrid,
|
||||
) : ViewModel() {
|
||||
|
||||
/**
|
||||
@@ -121,7 +121,7 @@ abstract class PagedTimelineViewModel(
|
||||
* The grid for a given anchor, held eagerly so a screen can place its pager
|
||||
* on its very first frame rather than a frame after the anchor moved.
|
||||
*/
|
||||
val rule: StateFlow<(LocalDate) -> DaysGrid> =
|
||||
val rule: StateFlow<(LocalDate) -> PageGrid> =
|
||||
gridRule.stateIn(viewModelScope, SharingStarted.Eagerly, initialRule)
|
||||
|
||||
/** Today, kept current across midnight so a page left open doesn't mark yesterday (#373). */
|
||||
@@ -233,11 +233,11 @@ class WeekViewModel @Inject constructor(
|
||||
zoneSource = zoneSource,
|
||||
io = io,
|
||||
gridRule = settingsPrefs.weekStart.map { pref ->
|
||||
val grid = DaysGrid.weeks(pref.resolveFirstDay(Locale.getDefault()))
|
||||
val rule: (LocalDate) -> DaysGrid = { grid }
|
||||
val grid = WeekGrid(pref.resolveFirstDay(Locale.getDefault()))
|
||||
val rule: (LocalDate) -> PageGrid = { grid }
|
||||
rule
|
||||
},
|
||||
initialRule = { DaysGrid.weeks(DayOfWeek.MONDAY) },
|
||||
initialRule = { WeekGrid(DayOfWeek.MONDAY) },
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -278,8 +278,8 @@ class DayViewModel @Inject constructor(
|
||||
todaySource = todaySource,
|
||||
zoneSource = zoneSource,
|
||||
io = io,
|
||||
gridRule = flowOf { _: LocalDate -> DaysGrid.days },
|
||||
initialRule = { DaysGrid.days },
|
||||
gridRule = flowOf { _: LocalDate -> DayGrid },
|
||||
initialRule = { DayGrid },
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import de.jeanlucmakiola.floret.glance.reloadOnUpdate
|
||||
import de.jeanlucmakiola.floret.glance.restoreState
|
||||
|
||||
/**
|
||||
* What every widget receiver shares: the midnight rollover follows whether any
|
||||
* widget is placed (#228), a restore keeps each widget's settings (#292), and
|
||||
* the system's `updatePeriodMillis` wake-up reloads the data and re-arms the
|
||||
* alarm — anything that drops a pending alarm without a broadcast (force-stop,
|
||||
* battery restriction, an OEM freeze) is repaired there rather than on the
|
||||
* next app open.
|
||||
*/
|
||||
abstract class CalendulaWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
|
||||
override fun onEnabled(context: Context) {
|
||||
super.onEnabled(context)
|
||||
syncWidgetRollover(context)
|
||||
}
|
||||
|
||||
override fun onDisabled(context: Context) {
|
||||
super.onDisabled(context)
|
||||
syncWidgetRollover(context)
|
||||
}
|
||||
|
||||
override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) {
|
||||
restoreState(context, oldWidgetIds, newWidgetIds)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingSuperCall")
|
||||
override fun onUpdate(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
appWidgetIds: IntArray,
|
||||
) {
|
||||
// In place of super, which only recomposes and would take the goAsync handle.
|
||||
reloadOnUpdate(context, appWidgetIds)
|
||||
syncWidgetRollover(context)
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.appwidget.state.getAppWidgetState
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.floret.prefs.toEnum
|
||||
|
||||
/** A widget's own light/dark choice (#292); [FOLLOW_APP] tracks the app's theme setting. */
|
||||
enum class WidgetTheme { FOLLOW_APP, LIGHT, DARK }
|
||||
|
||||
/** How a widget looks, set per placed widget in its config screen (#292). */
|
||||
data class WidgetAppearance(
|
||||
val background: WidgetBackground = WidgetBackground.TINTED,
|
||||
val theme: WidgetTheme = WidgetTheme.FOLLOW_APP,
|
||||
)
|
||||
|
||||
/** What the agenda widget lists, per placed widget. */
|
||||
data class AgendaWidgetOptions(
|
||||
val range: AgendaRange,
|
||||
val textSize: WidgetSize,
|
||||
val showToday: Boolean,
|
||||
val pastDisplay: PastEventDisplay,
|
||||
)
|
||||
|
||||
internal fun WidgetTheme.resolve(app: ThemeMode): WidgetThemeMode = when (this) {
|
||||
WidgetTheme.LIGHT -> WidgetThemeMode.LIGHT
|
||||
WidgetTheme.DARK -> WidgetThemeMode.DARK
|
||||
WidgetTheme.FOLLOW_APP -> when (app) {
|
||||
ThemeMode.SYSTEM -> WidgetThemeMode.SYSTEM
|
||||
ThemeMode.LIGHT -> WidgetThemeMode.LIGHT
|
||||
ThemeMode.DARK, ThemeMode.BLACK -> WidgetThemeMode.DARK
|
||||
}
|
||||
}
|
||||
|
||||
private val BackgroundKey = stringPreferencesKey("cfg_background")
|
||||
private val ThemeKey = stringPreferencesKey("cfg_theme")
|
||||
private val AgendaRangeKey = stringPreferencesKey("cfg_agenda_range")
|
||||
private val TextSizeKey = stringPreferencesKey("cfg_text_size")
|
||||
private val ShowTodayKey = booleanPreferencesKey("cfg_show_today")
|
||||
private val PastDisplayKey = stringPreferencesKey("cfg_past_display")
|
||||
private val HiddenCalendarsKey = stringSetPreferencesKey("cfg_hidden_calendars")
|
||||
|
||||
internal val Preferences.widgetAppearance: WidgetAppearance
|
||||
get() = WidgetAppearance(
|
||||
background = this[BackgroundKey].toEnum(WidgetBackground.TINTED),
|
||||
theme = this[ThemeKey].toEnum(WidgetTheme.FOLLOW_APP),
|
||||
)
|
||||
|
||||
/**
|
||||
* This widget's agenda options. A widget placed before per-widget options
|
||||
* existed has none stored, and keeps following the app-wide values in
|
||||
* [defaults] until it is configured.
|
||||
*/
|
||||
internal fun Preferences.agendaWidgetOptions(defaults: AgendaWidgetOptions) = AgendaWidgetOptions(
|
||||
range = parseAgendaRange(this[AgendaRangeKey], defaults.range),
|
||||
textSize = this[TextSizeKey].toEnum(defaults.textSize),
|
||||
showToday = this[ShowTodayKey] ?: defaults.showToday,
|
||||
pastDisplay = this[PastDisplayKey].toEnum(defaults.pastDisplay),
|
||||
)
|
||||
|
||||
/** Whether this widget has agenda options of its own, rather than following the app's. */
|
||||
internal val Preferences.hasAgendaOptions: Boolean get() = this[AgendaRangeKey] != null
|
||||
|
||||
internal fun MutablePreferences.putAppearance(appearance: WidgetAppearance) {
|
||||
this[BackgroundKey] = appearance.background.name
|
||||
this[ThemeKey] = appearance.theme.name
|
||||
}
|
||||
|
||||
internal fun MutablePreferences.putAgendaOptions(options: AgendaWidgetOptions) {
|
||||
this[AgendaRangeKey] = options.range.storageValue()
|
||||
this[TextSizeKey] = options.textSize.name
|
||||
this[ShowTodayKey] = options.showToday
|
||||
this[PastDisplayKey] = options.pastDisplay.name
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendars this widget leaves out, on top of the ones hidden app-wide. Stored
|
||||
* as the excluded set, so a calendar added later shows up by default.
|
||||
*/
|
||||
internal val Preferences.hiddenCalendars: Set<Long>
|
||||
get() = this[HiddenCalendarsKey]?.mapNotNull { it.toLongOrNull() }?.toSet().orEmpty()
|
||||
|
||||
internal fun MutablePreferences.putHiddenCalendars(ids: Set<Long>) {
|
||||
this[HiddenCalendarsKey] = ids.map(Long::toString).toSet()
|
||||
}
|
||||
|
||||
internal fun List<EventInstance>.withoutCalendars(hidden: Set<Long>): List<EventInstance> =
|
||||
if (hidden.isEmpty()) this else filterNot { it.calendarId in hidden }
|
||||
|
||||
internal suspend fun Context.widgetState(id: GlanceId): Preferences =
|
||||
getAppWidgetState(this, PreferencesGlanceStateDefinition, id)
|
||||
@@ -4,12 +4,7 @@ import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.glance.GlanceId
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
@@ -18,12 +13,7 @@ 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
|
||||
@@ -35,7 +25,6 @@ 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
|
||||
|
||||
@@ -45,63 +34,10 @@ import kotlin.time.Instant
|
||||
* the whole window, and on slow devices a wide one held the widget on "loading"
|
||||
* (#363).
|
||||
*/
|
||||
internal const val MONTH_WIDGET_PAD_MONTHS = 3
|
||||
|
||||
/**
|
||||
* Every app setting the widgets draw with: what the loads read, and what
|
||||
* [WidgetRefreshCoordinator] watches. The agenda fields are only the defaults
|
||||
* for a widget that has no options of its own yet.
|
||||
*/
|
||||
internal data class WidgetSettings(
|
||||
val soften: Boolean,
|
||||
val weekStart: WeekStartPref,
|
||||
val timeFormat: TimeFormatPref,
|
||||
val themeMode: ThemeMode,
|
||||
val pastDisplay: PastEventDisplay,
|
||||
val agendaRange: AgendaRange,
|
||||
val widgetSize: WidgetSize,
|
||||
val showToday: Boolean,
|
||||
) {
|
||||
/** What every widget draws with; the agenda defaults aside. */
|
||||
val shared: SharedWidgetSettings get() = SharedWidgetSettings(soften, weekStart, timeFormat, themeMode)
|
||||
|
||||
val agendaDefaults: AgendaWidgetOptions
|
||||
get() = AgendaWidgetOptions(agendaRange, widgetSize, showToday, pastDisplay)
|
||||
}
|
||||
|
||||
/** The [WidgetSettings] a configured widget still takes from the app. */
|
||||
internal data class SharedWidgetSettings(
|
||||
val soften: Boolean,
|
||||
val weekStart: WeekStartPref,
|
||||
val timeFormat: TimeFormatPref,
|
||||
val themeMode: ThemeMode,
|
||||
)
|
||||
|
||||
/** A widget's loaded [content], with the app theme a [WidgetTheme.FOLLOW_APP] widget resolves against. */
|
||||
internal data class Themed<out T>(val content: T, val appTheme: ThemeMode)
|
||||
|
||||
internal val SettingsPrefs.widgetSettings: Flow<WidgetSettings>
|
||||
get() = data.map {
|
||||
WidgetSettings(
|
||||
soften = it[SettingsKeys.softenCalendarColors],
|
||||
weekStart = it[SettingsKeys.weekStart],
|
||||
timeFormat = it[SettingsKeys.timeFormat],
|
||||
themeMode = it[SettingsKeys.themeMode],
|
||||
pastDisplay = it[SettingsKeys.pastEventDisplay],
|
||||
agendaRange = it[SettingsKeys.agendaWidgetRange],
|
||||
widgetSize = it[SettingsKeys.widgetSize],
|
||||
showToday = it[SettingsKeys.agendaShowToday],
|
||||
)
|
||||
}.distinctUntilChanged()
|
||||
private const val MONTH_WIDGET_PAD_MONTHS = 3
|
||||
|
||||
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
|
||||
|
||||
@@ -115,74 +51,112 @@ sealed interface AgendaWidgetData {
|
||||
data object NeedsPermission : AgendaWidgetData
|
||||
data class Ready(
|
||||
val today: LocalDate,
|
||||
/** Days with events across the configured range, unfiltered; hiding finished ones happens in the composition. */
|
||||
/**
|
||||
* Days with events across the **widest** selectable window. The displayed
|
||||
* range is sliced from this in the composition (read reactively from
|
||||
* Glance state), so a range change is pure recomposition rather than a
|
||||
* flaky widget session restart — the same approach the month widget uses.
|
||||
* Kept unfiltered: hiding/dimming finished events also happens reactively
|
||||
* in the composition, against [now], for the same reliable-update reason.
|
||||
*/
|
||||
val days: List<AgendaDay>,
|
||||
/** 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,
|
||||
val pastDisplay: PastEventDisplay,
|
||||
/** "Always show today" (#35): anchor today at the top even with no events left. */
|
||||
val showToday: Boolean,
|
||||
val textSize: WidgetSize,
|
||||
/** 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,
|
||||
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
|
||||
val now: Instant,
|
||||
) : AgendaWidgetData
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
sealed interface MonthWidgetSource {
|
||||
data object NeedsPermission : MonthWidgetSource
|
||||
data class Ready(
|
||||
val today: LocalDate,
|
||||
val weekStart: DayOfWeek,
|
||||
val soften: Boolean,
|
||||
val instances: List<EventInstance>,
|
||||
/** Month indices (`year * 12 + monthOrdinal`) whose grids [instances] covers. */
|
||||
val months: IntRange,
|
||||
) : MonthWidgetSource
|
||||
}
|
||||
|
||||
/** [loadAgendaWidgetData] with agenda widget [id]'s own options. */
|
||||
internal suspend fun Context.loadAgendaWidget(id: GlanceId): Themed<AgendaWidgetData> {
|
||||
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)
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
settings: WidgetSettings,
|
||||
options: AgendaWidgetOptions,
|
||||
hiddenCalendars: Set<Long>,
|
||||
): AgendaWidgetData {
|
||||
internal suspend fun Context.loadAgendaWidgetData(range: AgendaRange? = null): AgendaWidgetData {
|
||||
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
|
||||
val zone = systemZone()
|
||||
val anchor = today(zone)
|
||||
val ep = widgetEntryPoint()
|
||||
val weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault())
|
||||
val dayCount = options.range.dayCount(anchor, weekStart)
|
||||
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 window = agendaRange(anchor, dayCount - 1, zone)
|
||||
val windowEnd = anchor.plus(dayCount - 1, DateTimeUnit.DAY)
|
||||
val instances = ep.calendarRepository().instances(window).first().withoutCalendars(hiddenCalendars)
|
||||
val instances = ep.calendarRepository().instances(window).first()
|
||||
val is24Hour = prefs.timeFormat.first()
|
||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
|
||||
val soften = prefs.softenCalendarColors.first()
|
||||
return AgendaWidgetData.Ready(
|
||||
today = anchor,
|
||||
days = groupAgendaDays(anchor, windowEnd, instances, zone),
|
||||
is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)),
|
||||
soften = settings.soften,
|
||||
pastDisplay = options.pastDisplay,
|
||||
showToday = options.showToday,
|
||||
textSize = options.textSize,
|
||||
is24Hour = is24Hour,
|
||||
soften = soften,
|
||||
weekStart = weekStart,
|
||||
savedRange = savedRange,
|
||||
savedPastDisplay = savedPastDisplay,
|
||||
savedShowToday = showToday,
|
||||
savedWidgetSize = prefs.widgetSize.first(),
|
||||
loadedThrough = windowEnd,
|
||||
now = Clock.System.now(),
|
||||
)
|
||||
}
|
||||
@@ -191,55 +165,17 @@ internal fun monthIndexOf(date: LocalDate): Int = date.year * 12 + date.month.or
|
||||
|
||||
private fun firstDayOfMonthIndex(index: Int): LocalDate = LocalDate(index / 12, index % 12 + 1, 1)
|
||||
|
||||
/**
|
||||
* The month the widget loads its window around while [index] is shown. Snapped
|
||||
* to steps of three, so paging only reloads every third month, and the window
|
||||
* always reaches at least two months past [index] either way.
|
||||
*/
|
||||
internal fun monthWindowCenter(index: Int): Int = Math.floorDiv(index, 3) * 3 + 1
|
||||
|
||||
/**
|
||||
* The last window each month widget loaded, under its refresh stamp, so a new
|
||||
* session for the same widget (a tap after the last one timed out) draws
|
||||
* without a query. Every redraw moves the stamp, so an entry is never served
|
||||
* stale.
|
||||
*/
|
||||
private object MonthSourceCache {
|
||||
private class Entry(val center: Int, val stamp: Long, val source: Themed<MonthWidgetSource.Ready>)
|
||||
|
||||
private val entries = ConcurrentHashMap<GlanceId, Entry>()
|
||||
|
||||
fun get(id: GlanceId, center: Int, stamp: Long): Themed<MonthWidgetSource>? =
|
||||
entries[id]?.takeIf { it.center == center && it.stamp == stamp && it.source.content.today == today(systemZone()) }
|
||||
?.source
|
||||
|
||||
fun put(id: GlanceId, center: Int, stamp: Long, source: Themed<MonthWidgetSource>) {
|
||||
val ready = source.content as? MonthWidgetSource.Ready ?: return
|
||||
entries[id] = Entry(center, stamp, Themed(ready, source.appTheme))
|
||||
}
|
||||
}
|
||||
|
||||
/** [loadThemedMonthSource] for widget [id], served from [MonthSourceCache] while its [stamp] holds. */
|
||||
internal suspend fun Context.loadCachedMonthSource(id: GlanceId, centerIndex: Int, stamp: Long): Themed<MonthWidgetSource> =
|
||||
MonthSourceCache.get(id, centerIndex, stamp)
|
||||
?: loadThemedMonthSource(centerIndex, widgetState(id).hiddenCalendars)
|
||||
.also { MonthSourceCache.put(id, centerIndex, stamp, it) }
|
||||
|
||||
/** Read backing the month widget's grid for the window around [centerIndex], with the app theme. */
|
||||
internal suspend fun Context.loadThemedMonthSource(centerIndex: Int, hiddenCalendars: Set<Long>): Themed<MonthWidgetSource> {
|
||||
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
|
||||
return Themed(queryMonthSource(centerIndex, settings, hiddenCalendars), settings.themeMode)
|
||||
}
|
||||
|
||||
private suspend fun Context.queryMonthSource(
|
||||
centerIndex: Int,
|
||||
settings: WidgetSettings,
|
||||
hiddenCalendars: Set<Long>,
|
||||
): MonthWidgetSource {
|
||||
/** Read backing the month widget's grid for [centerIndex] and the months around it. */
|
||||
internal suspend fun Context.loadMonthWidgetSource(centerIndex: Int? = null): 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 months = (centerIndex - MONTH_WIDGET_PAD_MONTHS)..(centerIndex + MONTH_WIDGET_PAD_MONTHS)
|
||||
val weekStart = ep.settingsPrefs().weekStart.first().resolveFirstDay(Locale.getDefault())
|
||||
val months = (center - MONTH_WIDGET_PAD_MONTHS)..(center + MONTH_WIDGET_PAD_MONTHS)
|
||||
// Padded by the grid's spill into the neighbouring months.
|
||||
val from = firstDayOfMonthIndex(months.first)
|
||||
.minus(1, DateTimeUnit.WEEK)
|
||||
@@ -248,79 +184,11 @@ private suspend fun Context.queryMonthSource(
|
||||
.plus(2, DateTimeUnit.WEEK)
|
||||
.atTime(23, 59, 59)
|
||||
.toInstant(zone)
|
||||
val instances = ep.calendarRepository().instances(from..to).first()
|
||||
return MonthWidgetSource.Ready(
|
||||
today = today(zone),
|
||||
weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()),
|
||||
soften = settings.soften,
|
||||
instances = ep.calendarRepository().instances(from..to).first().withoutCalendars(hiddenCalendars),
|
||||
today = anchor,
|
||||
weekStart = weekStart,
|
||||
instances = instances,
|
||||
months = months,
|
||||
)
|
||||
}
|
||||
|
||||
/** Today and the app theme, for a widget that draws no events. */
|
||||
internal suspend fun Context.loadToday(): Themed<LocalDate> =
|
||||
Themed(today(systemZone()), widgetEntryPoint().settingsPrefs().widgetSettings.first().themeMode)
|
||||
|
||||
/** What a widget drawing a run of days loads: those days' events, grouped like the agenda. */
|
||||
sealed interface DaysWidgetData {
|
||||
data object NeedsPermission : DaysWidgetData
|
||||
data class Ready(
|
||||
val today: LocalDate,
|
||||
val firstDay: LocalDate,
|
||||
val lastDay: LocalDate,
|
||||
/** Only the days with events, in order. */
|
||||
val days: List<AgendaDay>,
|
||||
val weekStart: DayOfWeek,
|
||||
val is24Hour: Boolean,
|
||||
val soften: Boolean,
|
||||
val options: AgendaWidgetOptions,
|
||||
val now: Instant,
|
||||
) : DaysWidgetData {
|
||||
fun eventsOn(date: LocalDate): List<EventInstance> = days.firstOrNull { it.date == date }?.events.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
/** Widget [id]'s stored options and calendars, with the app's values for what it has none of. */
|
||||
internal class WidgetLoadContext(val settings: WidgetSettings, val options: AgendaWidgetOptions, val hiddenCalendars: Set<Long>)
|
||||
|
||||
internal suspend fun Context.widgetLoadContext(id: GlanceId): WidgetLoadContext {
|
||||
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
|
||||
val state = widgetState(id)
|
||||
return WidgetLoadContext(settings, state.agendaWidgetOptions(settings.agendaDefaults), state.hiddenCalendars)
|
||||
}
|
||||
|
||||
/** The events on the days [days] picks from today and the week start, for [load]'s widget. */
|
||||
internal suspend fun Context.loadDays(
|
||||
load: WidgetLoadContext,
|
||||
days: (today: LocalDate, weekStart: DayOfWeek) -> ClosedRange<LocalDate>,
|
||||
): Themed<DaysWidgetData> {
|
||||
val settings = load.settings
|
||||
if (!hasCalendarPermission()) return Themed(DaysWidgetData.NeedsPermission, settings.themeMode)
|
||||
val zone = systemZone()
|
||||
val anchor = today(zone)
|
||||
val weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault())
|
||||
val range = days(anchor, weekStart)
|
||||
val window = range.start.atStartOfDayIn(zone)..range.endInclusive.atTime(23, 59, 59).toInstant(zone)
|
||||
val instances = widgetEntryPoint().calendarRepository().instances(window).first().withoutCalendars(load.hiddenCalendars)
|
||||
return Themed(
|
||||
DaysWidgetData.Ready(
|
||||
today = anchor,
|
||||
firstDay = range.start,
|
||||
lastDay = range.endInclusive,
|
||||
days = groupAgendaDays(range.start, range.endInclusive, instances, zone),
|
||||
weekStart = weekStart,
|
||||
is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)),
|
||||
soften = settings.soften,
|
||||
options = load.options,
|
||||
now = Clock.System.now(),
|
||||
),
|
||||
settings.themeMode,
|
||||
)
|
||||
}
|
||||
|
||||
/** The week [today] falls in, from [weekStart]. */
|
||||
internal fun weekOf(today: LocalDate, weekStart: DayOfWeek): ClosedRange<LocalDate> {
|
||||
val back = (today.dayOfWeek.ordinal - weekStart.ordinal + 7) % 7
|
||||
val first = today.minus(back, DateTimeUnit.DAY)
|
||||
return first..first.plus(6, DateTimeUnit.DAY)
|
||||
).also { MonthWidgetCache.data = it }
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
interface WidgetEntryPoint {
|
||||
fun calendarRepository(): CalendarRepository
|
||||
fun settingsPrefs(): SettingsPrefs
|
||||
fun widgetRefreshCoordinator(): WidgetRefreshCoordinator
|
||||
}
|
||||
|
||||
internal fun Context.widgetEntryPoint(): WidgetEntryPoint =
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.glance.WidgetGroupedRow
|
||||
import de.jeanlucmakiola.floret.glance.themedColor
|
||||
|
||||
/** Text and row sizes of a compact event list, from a widget's [WidgetSize]. */
|
||||
internal data class EventRowMetrics(val title: TextUnit, val time: TextUnit, val rowHeight: Dp, val message: TextUnit)
|
||||
|
||||
/** The agenda widget's sizes with tighter rows, resolved against the system font scale. */
|
||||
internal fun compactRowMetrics(size: WidgetSize, fontScale: Float): EventRowMetrics {
|
||||
val agenda = metricsFor(size)
|
||||
return EventRowMetrics(
|
||||
title = agenda.eventTitle,
|
||||
time = agenda.eventTime,
|
||||
rowHeight = agenda.contentH * fontScale + agenda.rowVPad,
|
||||
message = agenda.message,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One event as a two-line row in its calendar's fill, part of a grouped card
|
||||
* at [position]; opens the occurrence. [quiet] draws it in the card colour
|
||||
* instead, for events shown only as a look ahead.
|
||||
*/
|
||||
@Composable
|
||||
internal fun WidgetEventRow(
|
||||
event: EventInstance,
|
||||
secondary: String,
|
||||
soften: Boolean,
|
||||
metrics: EventRowMetrics,
|
||||
position: Position,
|
||||
source: CalendarView,
|
||||
dimmed: Boolean = false,
|
||||
quiet: Boolean = false,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
fun tone(dark: Boolean): Color = eventFill(event.color, dark, soften).let { if (dimmed) it.copy(alpha = EventDimAlpha) else it }
|
||||
val day = tone(dark = false)
|
||||
val night = tone(dark = true)
|
||||
// Ink is picked against the opaque fill, then carries the dim along with it.
|
||||
fun ink(fill: Color, alpha: Float) = eventInk(fill.copy(alpha = 1f), alpha * fill.alpha)
|
||||
val card = widgetCardColor()
|
||||
WidgetGroupedRow(position = position, color = if (quiet) card else themedColor(day = day, night = night)) {
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.height(metrics.rowHeight)
|
||||
.padding(horizontal = 12.dp)
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openEventIntent(
|
||||
context = context,
|
||||
eventId = event.eventId,
|
||||
beginMillis = event.start.toEpochMilliseconds(),
|
||||
endMillis = event.end.toEpochMilliseconds(),
|
||||
source = source,
|
||||
),
|
||||
),
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val titleInk = if (quiet) {
|
||||
GlanceTheme.colors.onSurface
|
||||
} else {
|
||||
themedColor(day = ink(day, 1f), night = ink(night, 1f))
|
||||
}
|
||||
val timeInk = if (quiet) {
|
||||
GlanceTheme.colors.onSurfaceVariant
|
||||
} else {
|
||||
themedColor(day = ink(day, SECONDARY_INK_ALPHA), night = ink(night, SECONDARY_INK_ALPHA))
|
||||
}
|
||||
Text(
|
||||
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = titleInk,
|
||||
fontSize = metrics.title,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textDecoration = glanceDeclinedDecoration(event.isDeclined),
|
||||
),
|
||||
)
|
||||
Text(text = secondary, maxLines = 1, style = TextStyle(color = timeInk, fontSize = metrics.time))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidget
|
||||
import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.date.DateWidget
|
||||
import de.jeanlucmakiola.calendula.widget.date.DateWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.week.WeekStripWidget
|
||||
import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidget
|
||||
import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.today.TodayWidget
|
||||
import de.jeanlucmakiola.calendula.widget.today.TodayWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidget
|
||||
import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.split.MonthDayWidget
|
||||
import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetReceiver
|
||||
|
||||
/** A content option a widget's config screen offers, besides background and theme. */
|
||||
enum class WidgetOption { RANGE, TEXT_SIZE, PAST_DISPLAY, SHOW_TODAY, CALENDARS }
|
||||
|
||||
/**
|
||||
* Every widget the app ships, told apart by their receiver: what redraws,
|
||||
* the rollover and the config screen go through.
|
||||
*/
|
||||
enum class WidgetKind(
|
||||
val receiver: Class<*>,
|
||||
val label: Int,
|
||||
val options: Set<WidgetOption>,
|
||||
/** The size a config preview draws at before the launcher reports one. */
|
||||
val defaultSize: DpSize,
|
||||
) {
|
||||
AGENDA(
|
||||
AgendaWidgetReceiver::class.java,
|
||||
R.string.widget_agenda_label,
|
||||
setOf(WidgetOption.RANGE, WidgetOption.TEXT_SIZE, WidgetOption.PAST_DISPLAY, WidgetOption.SHOW_TODAY, WidgetOption.CALENDARS),
|
||||
DpSize(260.dp, 280.dp),
|
||||
),
|
||||
MONTH(
|
||||
MonthWidgetReceiver::class.java,
|
||||
R.string.widget_month_label,
|
||||
setOf(WidgetOption.CALENDARS),
|
||||
DpSize(320.dp, 340.dp),
|
||||
),
|
||||
QUICK_ADD(
|
||||
QuickAddWidgetReceiver::class.java,
|
||||
R.string.widget_quick_add_label,
|
||||
emptySet(),
|
||||
DpSize(76.dp, 76.dp),
|
||||
),
|
||||
DATE(
|
||||
DateWidgetReceiver::class.java,
|
||||
R.string.widget_date_label,
|
||||
emptySet(),
|
||||
DpSize(76.dp, 76.dp),
|
||||
),
|
||||
WEEK_STRIP(
|
||||
WeekStripWidgetReceiver::class.java,
|
||||
R.string.widget_week_strip_label,
|
||||
setOf(WidgetOption.CALENDARS),
|
||||
DpSize(300.dp, 90.dp),
|
||||
),
|
||||
UPCOMING(
|
||||
UpcomingWidgetReceiver::class.java,
|
||||
R.string.widget_upcoming_label,
|
||||
setOf(WidgetOption.RANGE, WidgetOption.TEXT_SIZE, WidgetOption.CALENDARS),
|
||||
DpSize(180.dp, 180.dp),
|
||||
),
|
||||
TODAY(
|
||||
TodayWidgetReceiver::class.java,
|
||||
R.string.widget_today_label,
|
||||
setOf(WidgetOption.TEXT_SIZE, WidgetOption.PAST_DISPLAY, WidgetOption.CALENDARS),
|
||||
DpSize(300.dp, 100.dp),
|
||||
),
|
||||
COUNTDOWN(
|
||||
CountdownWidgetReceiver::class.java,
|
||||
R.string.widget_countdown_label,
|
||||
setOf(WidgetOption.CALENDARS),
|
||||
DpSize(200.dp, 100.dp),
|
||||
),
|
||||
MONTH_DAY(
|
||||
MonthDayWidgetReceiver::class.java,
|
||||
R.string.widget_month_day_label,
|
||||
setOf(WidgetOption.TEXT_SIZE, WidgetOption.PAST_DISPLAY, WidgetOption.CALENDARS),
|
||||
DpSize(320.dp, 200.dp),
|
||||
),
|
||||
;
|
||||
|
||||
fun widget(): GlanceAppWidget = when (this) {
|
||||
AGENDA -> AgendaWidget()
|
||||
MONTH -> MonthWidget()
|
||||
QUICK_ADD -> QuickAddWidget()
|
||||
DATE -> DateWidget()
|
||||
WEEK_STRIP -> WeekStripWidget()
|
||||
UPCOMING -> UpcomingWidget()
|
||||
TODAY -> TodayWidget()
|
||||
COUNTDOWN -> CountdownWidget()
|
||||
MONTH_DAY -> MonthDayWidget()
|
||||
}
|
||||
|
||||
/** Whether this kind stores [AgendaWidgetOptions] of its own. */
|
||||
val hasContentOptions: Boolean
|
||||
get() = options.any { it != WidgetOption.CALENDARS }
|
||||
|
||||
companion object {
|
||||
fun of(context: Context, appWidgetId: Int): WidgetKind? {
|
||||
val provider = AppWidgetManager.getInstance(context)?.getAppWidgetInfo(appWidgetId)?.provider ?: return null
|
||||
return entries.firstOrNull { it.receiver.name == provider.className }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.unit.ColorProvider
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.agendaTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toJavaDayOfWeek
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import java.util.Locale
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Cards sit one tone off the root fill, and stay opaque over a wallpaper (#292). */
|
||||
@Composable
|
||||
internal fun widgetCardColor(): ColorProvider = when (LocalWidgetBackground.current) {
|
||||
WidgetBackground.SOLID -> GlanceTheme.colors.surfaceVariant
|
||||
WidgetBackground.TINTED, WidgetBackground.TRANSPARENT -> GlanceTheme.colors.widgetBackground
|
||||
}
|
||||
|
||||
/** An event's time on [day]: "All day", "Starts 14:00", "Ends 9:00" or "14:00 – 15:30". */
|
||||
internal fun widgetTimeLabel(context: Context, event: EventInstance, day: LocalDate, is24Hour: Boolean): String =
|
||||
when (val label = agendaTimeLabel(event, day, systemZone())) {
|
||||
AgendaTimeLabel.AllDay -> context.getString(R.string.event_detail_all_day)
|
||||
is AgendaTimeLabel.Starts -> context.getString(R.string.agenda_span_starts, formatWidgetTime(label.start, is24Hour))
|
||||
is AgendaTimeLabel.Ends -> context.getString(R.string.agenda_span_ends, formatWidgetTime(label.end, is24Hour))
|
||||
is AgendaTimeLabel.Range -> "${formatWidgetTime(label.start, is24Hour)} – ${formatWidgetTime(label.end, is24Hour)}"
|
||||
}
|
||||
|
||||
internal fun formatWidgetTime(instant: Instant, is24Hour: Boolean): String {
|
||||
val t = instant.toLocalDateTime(systemZone()).time
|
||||
return formatTimeOfDay(t.hour, t.minute, is24Hour, Locale.getDefault())
|
||||
}
|
||||
|
||||
/** "Today", "Tomorrow", or the short weekday within the coming week, else the short date. */
|
||||
internal fun relativeDayLabel(context: Context, date: LocalDate, today: LocalDate): String {
|
||||
val locale = Locale.getDefault()
|
||||
return when {
|
||||
date == today -> context.getString(R.string.agenda_header_today)
|
||||
date == today.plus(1, DateTimeUnit.DAY) -> context.getString(R.string.agenda_header_tomorrow)
|
||||
date < today.plus(7, DateTimeUnit.DAY) && date > today ->
|
||||
date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, locale)
|
||||
else -> localizedDateFormatter(locale, "dMMM").format(date.toJavaLocalDate())
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetContent
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.countdown.CountdownData
|
||||
import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions
|
||||
import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetContent
|
||||
import de.jeanlucmakiola.calendula.widget.countdown.CountdownWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.date.DateWidgetContent
|
||||
import de.jeanlucmakiola.calendula.widget.date.DateWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetContent
|
||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetContent
|
||||
import de.jeanlucmakiola.calendula.widget.quickadd.QuickAddWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.split.MonthDayData
|
||||
import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetContent
|
||||
import de.jeanlucmakiola.calendula.widget.split.MonthDayWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.today.TodayWidgetContent
|
||||
import de.jeanlucmakiola.calendula.widget.today.TodayWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetContent
|
||||
import de.jeanlucmakiola.calendula.widget.upcoming.UpcomingWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetContent
|
||||
import de.jeanlucmakiola.calendula.widget.week.WeekStripWidgetReceiver
|
||||
import de.jeanlucmakiola.floret.glance.publishWidgetPreviews
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import java.util.Locale
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/** Republishes the generated picker previews (Android 15+) after an app update or a language change. */
|
||||
internal suspend fun publishCalendulaWidgetPreviews(context: Context) {
|
||||
publishWidgetPreviews(
|
||||
context,
|
||||
version = "${context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime}-${Locale.getDefault().toLanguageTag()}",
|
||||
receivers = listOf(
|
||||
AgendaWidgetReceiver::class,
|
||||
MonthWidgetReceiver::class,
|
||||
QuickAddWidgetReceiver::class,
|
||||
DateWidgetReceiver::class,
|
||||
WeekStripWidgetReceiver::class,
|
||||
UpcomingWidgetReceiver::class,
|
||||
TodayWidgetReceiver::class,
|
||||
CountdownWidgetReceiver::class,
|
||||
MonthDayWidgetReceiver::class,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** The size [kind]'s picker preview is laid out at. */
|
||||
internal fun previewSizeOf(kind: WidgetKind) = SizeMode.Responsive(setOf(kind.defaultSize))
|
||||
|
||||
/** [kind]'s picker preview: the real widget, drawn with sample events around today's morning. */
|
||||
internal suspend fun GlanceAppWidget.provideSamplePreview(context: Context, kind: WidgetKind) {
|
||||
val settings = context.widgetEntryPoint().settingsPrefs().widgetSettings.first()
|
||||
val zone = systemZone()
|
||||
val today = today(zone)
|
||||
val now = today.atTime(8, 0).toInstant(zone)
|
||||
val instances = context.sampleInstances(today, zone)
|
||||
val weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault())
|
||||
val is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||
val options = settings.agendaDefaults
|
||||
val appearance = WidgetAppearance()
|
||||
val index = monthIndexOf(today)
|
||||
val month = MonthWidgetSource.Ready(today, weekStart, settings.soften, instances, index..index)
|
||||
fun days(range: ClosedRange<LocalDate>) = DaysWidgetData.Ready(
|
||||
today = today,
|
||||
firstDay = range.start,
|
||||
lastDay = range.endInclusive,
|
||||
days = groupAgendaDays(range.start, range.endInclusive, instances, zone),
|
||||
weekStart = weekStart,
|
||||
is24Hour = is24Hour,
|
||||
soften = settings.soften,
|
||||
options = options,
|
||||
now = now,
|
||||
)
|
||||
val theme = settings.themeMode
|
||||
provideContent {
|
||||
when (kind) {
|
||||
WidgetKind.AGENDA -> AgendaWidgetContent(
|
||||
AgendaWidgetData.Ready(
|
||||
today = today,
|
||||
days = groupAgendaDays(today, today.plus(6, DateTimeUnit.DAY), instances, zone),
|
||||
is24Hour = is24Hour,
|
||||
soften = settings.soften,
|
||||
pastDisplay = options.pastDisplay,
|
||||
showToday = options.showToday,
|
||||
textSize = options.textSize,
|
||||
now = now,
|
||||
),
|
||||
appearance,
|
||||
theme,
|
||||
preview = true,
|
||||
)
|
||||
WidgetKind.MONTH -> MonthWidgetContent(month, index, appearance, theme)
|
||||
WidgetKind.MONTH_DAY -> MonthDayWidgetContent(
|
||||
MonthDayData.Ready(month, options, is24Hour, now),
|
||||
index,
|
||||
selected = null,
|
||||
appearance = appearance,
|
||||
appTheme = theme,
|
||||
preview = true,
|
||||
)
|
||||
WidgetKind.QUICK_ADD -> QuickAddWidgetContent(today, appearance, theme)
|
||||
WidgetKind.DATE -> DateWidgetContent(today, appearance, theme)
|
||||
WidgetKind.WEEK_STRIP -> WeekStripWidgetContent(days(weekOf(today, weekStart)), appearance, theme)
|
||||
WidgetKind.UPCOMING -> UpcomingWidgetContent(days(today..today.plus(6, DateTimeUnit.DAY)), appearance, theme)
|
||||
WidgetKind.TODAY -> TodayWidgetContent(days(today..today.plus(1, DateTimeUnit.DAY)), appearance, theme, preview = true)
|
||||
WidgetKind.COUNTDOWN -> CountdownWidgetContent(
|
||||
CountdownData.Ready(
|
||||
options = CountdownOptions(),
|
||||
event = instances.first { !it.isAllDay && it.start > now },
|
||||
now = now,
|
||||
today = today,
|
||||
is24Hour = is24Hour,
|
||||
soften = settings.soften,
|
||||
appWidgetId = null,
|
||||
),
|
||||
appearance,
|
||||
theme,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A plausible few weeks: a busy today, a trip tomorrow, and a scatter of days with something on. */
|
||||
private fun Context.sampleInstances(today: LocalDate, zone: TimeZone): List<EventInstance> {
|
||||
var id = 0L
|
||||
fun timed(day: Int, at: LocalTime, length: kotlin.time.Duration, title: Int, color: Int): EventInstance {
|
||||
val start = today.plus(day, DateTimeUnit.DAY).atTime(at).toInstant(zone)
|
||||
id++
|
||||
return EventInstance(id, id, 1, getString(title), start, start + length, false, color, null)
|
||||
}
|
||||
fun allDay(day: Int, title: Int, color: Int): EventInstance {
|
||||
val date = today.plus(day, DateTimeUnit.DAY)
|
||||
id++
|
||||
return EventInstance(
|
||||
id, id, 1, getString(title),
|
||||
date.atStartOfDayIn(TimeZone.UTC), date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(TimeZone.UTC),
|
||||
true, color, null,
|
||||
)
|
||||
}
|
||||
return listOf(
|
||||
timed(0, LocalTime(9, 30), 30.minutes, R.string.settings_past_events_sample_morning, Blue),
|
||||
timed(0, LocalTime(12, 30), 1.hours, R.string.settings_past_events_sample_midday, Green),
|
||||
timed(0, LocalTime(19, 0), 2.hours, R.string.settings_past_events_sample_evening, Purple),
|
||||
allDay(1, R.string.widget_preview_sample_trip, Orange),
|
||||
timed(2, LocalTime(10, 0), 1.hours, R.string.widget_preview_sample_dentist, Red),
|
||||
timed(-6, LocalTime(9, 30), 30.minutes, R.string.settings_past_events_sample_morning, Blue),
|
||||
timed(-2, LocalTime(19, 0), 2.hours, R.string.settings_past_events_sample_evening, Purple),
|
||||
timed(5, LocalTime(19, 0), 2.hours, R.string.settings_past_events_sample_evening, Purple),
|
||||
timed(8, LocalTime(9, 30), 30.minutes, R.string.settings_past_events_sample_morning, Blue),
|
||||
timed(12, LocalTime(12, 30), 1.hours, R.string.settings_past_events_sample_midday, Green),
|
||||
).sortedBy { it.start }
|
||||
}
|
||||
|
||||
private const val Blue = 0xFF039BE5.toInt()
|
||||
private const val Green = 0xFF33B679.toInt()
|
||||
private const val Purple = 0xFF8E24AA.toInt()
|
||||
private const val Orange = 0xFFF4511E.toInt()
|
||||
private const val Red = 0xFFD50000.toInt()
|
||||
@@ -1,124 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.SystemClock
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.ApplicationScope
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.floret.glance.WidgetRefresher
|
||||
import de.jeanlucmakiola.floret.glance.WidgetRollover
|
||||
import de.jeanlucmakiola.floret.glance.updatePlaced
|
||||
import de.jeanlucmakiola.floret.glance.updateWithRefresh
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Every widget receiver; the set [WidgetRollover] checks for placed instances. */
|
||||
internal val WIDGET_RECEIVER_CLASSES: List<Class<*>> = WidgetKind.entries.map { it.receiver }
|
||||
|
||||
/** The app's own midnight wake-up (#228). */
|
||||
internal const val ACTION_WIDGET_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER"
|
||||
|
||||
/** Fixed: there is only ever one rollover alarm, and re-arming must replace it. */
|
||||
private const val ROLLOVER_REQUEST_CODE = 0x0DA1
|
||||
|
||||
internal fun widgetRolloverPendingIntent(context: Context): PendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
ROLLOVER_REQUEST_CODE,
|
||||
Intent(context, WidgetUpdateReceiver::class.java).setAction(ACTION_WIDGET_ROLLOVER),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
/** Arm the next midnight rollover, or cancel it when no widget is placed. Idempotent. */
|
||||
internal fun syncWidgetRollover(context: Context) =
|
||||
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
|
||||
|
||||
/**
|
||||
* Reloads and redraws every placed widget, a running session included: the
|
||||
* refresh stamp moves, and every widget keys its data load on it (#420).
|
||||
*/
|
||||
internal suspend fun redrawWidgets(context: Context) {
|
||||
WidgetKind.entries.forEach { redrawWidgets(context, it) }
|
||||
}
|
||||
|
||||
internal suspend fun redrawWidgets(context: Context, kind: WidgetKind) =
|
||||
kind.widget().updatePlaced(context, kind.receiver, bumpRefreshStamp = true)
|
||||
|
||||
/** Redraws only the widgets with no content options of their own, which still follow the app's. */
|
||||
private suspend fun redrawUnconfiguredWidgets(context: Context) {
|
||||
val appContext = context.applicationContext
|
||||
val manager = AppWidgetManager.getInstance(appContext) ?: return
|
||||
val glanceManager = GlanceAppWidgetManager(appContext)
|
||||
WidgetKind.entries.filter { it.hasContentOptions }.forEach { kind ->
|
||||
manager.getAppWidgetIds(ComponentName(appContext, kind.receiver)).forEach { appWidgetId ->
|
||||
val glanceId = glanceManager.getGlanceIdBy(appWidgetId)
|
||||
if (!appContext.widgetState(glanceId).hasAgendaOptions) kind.widget().updateWithRefresh(appContext, glanceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** When the app last saw a calendar change of its own, for skipping the `PROVIDER_CHANGED` that echoes it. */
|
||||
private val lastLocalChange = AtomicLong(0L)
|
||||
|
||||
private const val PROVIDER_ECHO_WINDOW_MILLIS = 5_000L
|
||||
|
||||
internal fun isProviderEcho(): Boolean {
|
||||
val last = lastLocalChange.get()
|
||||
return last != 0L && SystemClock.elapsedRealtime() - last < PROVIDER_ECHO_WINDOW_MILLIS
|
||||
}
|
||||
|
||||
/**
|
||||
* Redraws the widgets on the app's own calendar writes, visibility changes and
|
||||
* the settings they draw with, so those don't wait for `PROVIDER_CHANGED`,
|
||||
* which arrives seconds late or not at all (#420, #229). Changes from outside
|
||||
* the app are [WidgetUpdateReceiver]'s. The app's agenda options only redraw
|
||||
* widgets that have no content options of their own (#292). Started once from
|
||||
* `CalendulaApp`.
|
||||
*/
|
||||
@Singleton
|
||||
class WidgetRefreshCoordinator @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val repository: CalendarRepository,
|
||||
private val prefs: SettingsPrefs,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
) {
|
||||
private val started = AtomicBoolean(false)
|
||||
|
||||
/** Subscribes before returning, so a change made right after is never taken for the starting value. */
|
||||
fun start() {
|
||||
if (!started.compareAndSet(false, true)) return
|
||||
val all = WidgetKind.entries.map { kind -> WidgetRefresher(scope) { redrawWidgets(context, kind) } }
|
||||
val unconfigured = WidgetRefresher(scope) { redrawUnconfiguredWidgets(context) }
|
||||
watch(repository.localChanges) {
|
||||
lastLocalChange.set(SystemClock.elapsedRealtime())
|
||||
all.forEach { it.request() }
|
||||
}
|
||||
watch(prefs.widgetSettings.map { it.shared }.distinctUntilChanged().drop(1)) {
|
||||
all.forEach { it.request() }
|
||||
}
|
||||
watch(prefs.widgetSettings.map { it.agendaDefaults }.distinctUntilChanged().drop(1)) {
|
||||
unconfigured.request()
|
||||
}
|
||||
}
|
||||
|
||||
private fun watch(changes: Flow<*>, onChange: () -> Unit) {
|
||||
scope.launch(Dispatchers.IO, start = CoroutineStart.UNDISPATCHED) {
|
||||
changes.collect { onChange() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.getSystemService
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Holds the app's own wake-up for the next local midnight, so the home-screen
|
||||
* widgets roll "today" over on the day boundary (#228).
|
||||
*
|
||||
* The widgets used to lean on `ACTION_DATE_CHANGED`, which is not an exempted
|
||||
* implicit broadcast — a manifest-declared receiver has not been given it since
|
||||
* Android 8, leaving only the throttled `updatePeriodMillis`.
|
||||
*
|
||||
* Exactly one alarm exists at a time and every firing re-arms the next, the same
|
||||
* shape as [de.jeanlucmakiola.calendula.data.reminders.ReminderAlarmScheduler].
|
||||
* Deliberately **inexact**: `setAndAllowWhileIdle` needs no permission and
|
||||
* survives doze (plain `set` does not), a rollover a few minutes late is
|
||||
* invisible on a sleeping screen, and exact alarms stay reserved for snooze.
|
||||
*/
|
||||
object WidgetRolloverScheduler {
|
||||
|
||||
/**
|
||||
* Fire just *after* midnight: an alarm delivered a few milliseconds early
|
||||
* would still read the old date and re-arm for an instant later.
|
||||
*/
|
||||
internal val ROLLOVER_SLACK = 5.seconds
|
||||
|
||||
/**
|
||||
* Arm the next rollover, or cancel a pending one when no widget is placed.
|
||||
* Idempotent, so every trigger (boot, app start, widget added/removed, the
|
||||
* rollover itself, a clock or timezone change) can just call it.
|
||||
*/
|
||||
fun sync(context: Context) {
|
||||
val appContext = context.applicationContext
|
||||
val alarmManager = appContext.getSystemService<AlarmManager>() ?: return
|
||||
val pendingIntent = rolloverPendingIntent(appContext)
|
||||
if (!hasPlacedWidgets(appContext)) {
|
||||
alarmManager.cancel(pendingIntent)
|
||||
return
|
||||
}
|
||||
val triggerAt = nextRolloverAt(Clock.System.now(), TimeZone.currentSystemDefault())
|
||||
alarmManager.setAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP, triggerAt.toEpochMilliseconds(), pendingIntent,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The instant just after the next local midnight following [now] in [zone].
|
||||
*
|
||||
* The *actual* start of day, not 00:00, so it holds where a DST jump means
|
||||
* midnight never happens (Havana) and where a date-line move skips a whole
|
||||
* local date (Apia, December 2011) — the loop walks on to the next real day.
|
||||
*
|
||||
* The mirror case — a zone rewinding *across* midnight, so the day starts
|
||||
* twice — resolves to the earlier start and runs an hour ahead of the clock.
|
||||
* No live tz entry does that (Brazil dropped DST in 2019) and
|
||||
* `updatePeriodMillis` covers it, so it isn't worth state to detect.
|
||||
*/
|
||||
fun nextRolloverAt(now: Instant, zone: TimeZone): Instant {
|
||||
val date = now.toLocalDateTime(zone).date
|
||||
var days = 1
|
||||
while (days <= MAX_LOOKAHEAD_DAYS) {
|
||||
val candidate = date.plus(days, DateTimeUnit.DAY).atStartOfDayIn(zone) + ROLLOVER_SLACK
|
||||
if (candidate > now) return candidate
|
||||
days++
|
||||
}
|
||||
// Unreachable for any zone in the tz database. Deliberately an hour and
|
||||
// not the slack: a 5-second retry would just hit this branch again and
|
||||
// wake the device in a loop.
|
||||
return now + 1.hours
|
||||
}
|
||||
|
||||
private fun hasPlacedWidgets(context: Context): Boolean {
|
||||
val manager = AppWidgetManager.getInstance(context) ?: return false
|
||||
return PROVIDERS.any {
|
||||
manager.getAppWidgetIds(ComponentName(context, it)).isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
private fun rolloverPendingIntent(context: Context): PendingIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
ROLLOVER_REQUEST_CODE,
|
||||
Intent(context, WidgetUpdateReceiver::class.java)
|
||||
.setAction(WidgetUpdateReceiver.ACTION_ROLLOVER),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
private val PROVIDERS = listOf(
|
||||
MonthWidgetReceiver::class.java,
|
||||
AgendaWidgetReceiver::class.java,
|
||||
)
|
||||
|
||||
/** Fixed: there is only ever one rollover alarm, and re-arming must replace it. */
|
||||
private const val ROLLOVER_REQUEST_CODE = 0x0DA1
|
||||
|
||||
/** A gap of more than a couple of days does not exist in any tz database entry. */
|
||||
private const val MAX_LOOKAHEAD_DAYS = 3
|
||||
}
|
||||
@@ -1,36 +1,39 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.material3.ColorProviders
|
||||
import androidx.glance.text.TextDecoration
|
||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback
|
||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback
|
||||
import de.jeanlucmakiola.floret.glance.FloretGlanceTheme
|
||||
import de.jeanlucmakiola.floret.glance.WidgetSurface
|
||||
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* 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.
|
||||
*/
|
||||
@Composable
|
||||
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)
|
||||
fun CalendulaGlanceTheme(content: @Composable () -> Unit) {
|
||||
val colors = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
GlanceTheme.colors
|
||||
} else {
|
||||
CalendulaGlanceColors
|
||||
}
|
||||
GlanceTheme(colors = colors, content = content)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,29 +1,120 @@
|
||||
package de.jeanlucmakiola.calendula.widget
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import de.jeanlucmakiola.floret.glance.FloretWidgetUpdateReceiver
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
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
|
||||
|
||||
/**
|
||||
* Redraws both widgets when their data, day or language goes stale, and keeps
|
||||
* the midnight rollover armed. `PROVIDER_CHANGED` covers changes made outside
|
||||
* the app; the app's own writes go through [WidgetRefreshCoordinator]. A data
|
||||
* change also re-arms, at most every 15 minutes: the inexact alarm can be
|
||||
* dropped in a restricted standby bucket, and a calendar change is the one
|
||||
* signal that keeps arriving. A `PROVIDER_CHANGED` right after one of the
|
||||
* app's own changes only echoes it, so it doesn't redraw a second time.
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun rolloverPendingIntent(context: Context): PendingIntent = widgetRolloverPendingIntent(context)
|
||||
companion object {
|
||||
/** The app's own midnight wake-up; see [WidgetRolloverScheduler]. */
|
||||
const val ACTION_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER"
|
||||
|
||||
override fun skipRedraw(action: String) = action == Intent.ACTION_PROVIDER_CHANGED && isProviderEcho()
|
||||
/** Both wipe pending alarms, and the host redraws the widgets itself after them. */
|
||||
private val REARM_ONLY_ACTIONS = setOf(
|
||||
Intent.ACTION_BOOT_COMPLETED,
|
||||
Intent.ACTION_MY_PACKAGE_REPLACED,
|
||||
)
|
||||
|
||||
override suspend fun redraw(context: Context) = redrawWidgets(context)
|
||||
/**
|
||||
* The actions that leave no alarm standing: the two above wipe it, the
|
||||
* rollover consumed itself, and a clock, zone or day change moved the
|
||||
* boundary the pending one was set for.
|
||||
*/
|
||||
internal val REARM_ACTIONS = REARM_ONLY_ACTIONS + setOf(
|
||||
ACTION_ROLLOVER,
|
||||
Intent.ACTION_TIME_CHANGED,
|
||||
Intent.ACTION_TIMEZONE_CHANGED,
|
||||
Intent.ACTION_DATE_CHANGED,
|
||||
)
|
||||
|
||||
internal val HANDLED_ACTIONS = REARM_ACTIONS + Intent.ACTION_PROVIDER_CHANGED
|
||||
|
||||
/** How rarely a calendar change may re-arm; see the class doc. */
|
||||
private const val DATA_REARM_INTERVAL_MILLIS = 15 * 60_000L
|
||||
|
||||
/** Uptime of the last data-change re-arm, 0 for none in this process. */
|
||||
private val lastDataRearmMillis = AtomicLong(0L)
|
||||
|
||||
/**
|
||||
* Whether a calendar change may re-arm now — the backstop for an alarm
|
||||
* the system dropped, throttled so a burst of writes costs one pass.
|
||||
*/
|
||||
private fun dataRearmDue(): Boolean {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val last = lastDataRearmMillis.get()
|
||||
val due = last == 0L || now - last >= DATA_REARM_INTERVAL_MILLIS
|
||||
return due && lastDataRearmMillis.compareAndSet(last, now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,17 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
|
||||
/** Horizontal inset of a row's text inside its card; doesn't scale, so it never eats title width. */
|
||||
internal val ROW_H_PAD = 12.dp
|
||||
/**
|
||||
* 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
|
||||
|
||||
/** Space between the date-pill gutter and a day's card. */
|
||||
internal val GUTTER_GAP = 6.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
|
||||
|
||||
/**
|
||||
* Every size the agenda widget varies by [WidgetSize]. Values that genuinely
|
||||
@@ -18,91 +24,90 @@ internal val GUTTER_GAP = 6.dp
|
||||
* constants rather than routing through here.
|
||||
*/
|
||||
internal data class AgendaMetrics(
|
||||
val pillWeekday: TextUnit, // date pill weekday ("Thu")
|
||||
val pillDay: TextUnit, // date pill day number
|
||||
val title: TextUnit, // header "Upcoming"
|
||||
val dayHeader: TextUnit, // day label ("Today · …")
|
||||
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 contentH: Dp, // an event row's two text lines
|
||||
val pillWidth: Dp, // date pill / gutter width
|
||||
val stripeH: Dp, // coloured event stripe height
|
||||
val iconImage: Dp, // header action icon glyph
|
||||
val iconBox: Dp, // header action touch target
|
||||
val rowVPad: Dp, // event row vertical padding
|
||||
val dayGap: Dp, // space between two days' cards
|
||||
val monthLabel: TextUnit, // month name above a new month's first day
|
||||
val dayHeaderTopPad: Dp, // space above a day header
|
||||
) {
|
||||
/** 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 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.
|
||||
* 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.
|
||||
*/
|
||||
fun scaledForFont(fontScale: Float): AgendaMetrics =
|
||||
if (fontScale == 1f) this else copy(contentH = contentH * fontScale, pillWidth = pillWidth * fontScale)
|
||||
if (fontScale == 1f) this else copy(stripeH = stripeH * fontScale)
|
||||
}
|
||||
|
||||
/*
|
||||
* Type sizes are anchored to the Material 3 type scale, with one deviation:
|
||||
* Type sizes are anchored to the Material 3 type scale, with two deviations:
|
||||
*
|
||||
* ‡ 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(
|
||||
pillWeekday = 11.sp,
|
||||
pillDay = 16.sp,
|
||||
title = 16.sp, // M3 Title Medium
|
||||
dayHeader = 13.sp, // ‡ off-scale, holds the #51 baseline
|
||||
eventTitle = 14.sp, // M3 Body Medium
|
||||
eventTime = 12.sp, // M3 Body Small
|
||||
placeholder = 14.sp, // M3 Body Medium
|
||||
message = 14.sp, // M3 Body Medium
|
||||
contentH = 36.dp,
|
||||
pillWidth = 40.dp,
|
||||
stripeH = 36.dp,
|
||||
iconImage = 22.dp,
|
||||
iconBox = 40.dp,
|
||||
rowVPad = 4.dp,
|
||||
dayGap = 8.dp,
|
||||
monthLabel = 12.sp,
|
||||
dayHeaderTopPad = 10.dp,
|
||||
)
|
||||
|
||||
private val MEDIUM_METRICS = AgendaMetrics(
|
||||
pillWeekday = 12.sp,
|
||||
pillDay = 18.sp,
|
||||
title = 18.sp, // †
|
||||
dayHeader = 14.sp, // M3 Title Small
|
||||
eventTitle = 16.sp, // M3 Body Large
|
||||
eventTime = 14.sp, // M3 Body Medium
|
||||
placeholder = 16.sp, // M3 Body Large
|
||||
message = 16.sp, // M3 Body Large
|
||||
contentH = 40.dp,
|
||||
pillWidth = 44.dp,
|
||||
stripeH = 40.dp,
|
||||
iconImage = 24.dp,
|
||||
iconBox = 44.dp,
|
||||
rowVPad = 5.dp,
|
||||
dayGap = 9.dp,
|
||||
monthLabel = 14.sp,
|
||||
dayHeaderTopPad = 11.dp,
|
||||
)
|
||||
|
||||
private val LARGE_METRICS = AgendaMetrics(
|
||||
pillWeekday = 12.sp,
|
||||
pillDay = 20.sp,
|
||||
title = 20.sp, // †
|
||||
dayHeader = 16.sp, // M3 Title Medium
|
||||
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.
|
||||
contentH = 46.dp,
|
||||
pillWidth = 48.dp,
|
||||
stripeH = 46.dp,
|
||||
iconImage = 26.dp,
|
||||
iconBox = 48.dp,
|
||||
rowVPad = 6.dp,
|
||||
dayGap = 10.dp,
|
||||
monthLabel = 14.sp,
|
||||
dayHeaderTopPad = 12.dp,
|
||||
)
|
||||
|
||||
private val EXTRA_LARGE_METRICS = AgendaMetrics(
|
||||
pillWeekday = 14.sp,
|
||||
pillDay = 22.sp,
|
||||
title = 22.sp, // M3 Title Large
|
||||
dayHeader = 18.sp, // †
|
||||
eventTitle = 20.sp, // †
|
||||
eventTime = 16.sp, // M3 Body Large
|
||||
placeholder = 20.sp, // †
|
||||
message = 20.sp, // †
|
||||
contentH = 52.dp,
|
||||
pillWidth = 52.dp,
|
||||
stripeH = 52.dp,
|
||||
iconImage = 28.dp,
|
||||
iconBox = 52.dp,
|
||||
rowVPad = 8.dp,
|
||||
dayGap = 12.dp,
|
||||
monthLabel = 16.sp,
|
||||
dayHeaderTopPad = 14.dp,
|
||||
)
|
||||
|
||||
/** Indexed by [WidgetSize.ordinal] so lookup allocates nothing per recomposition. */
|
||||
|
||||
@@ -1,27 +1,40 @@
|
||||
package de.jeanlucmakiola.calendula.widget.agenda
|
||||
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.glance.ColorFilter
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.Image
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.action.ActionParameters
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.appwidget.action.ActionCallback
|
||||
import androidx.glance.appwidget.action.actionRunCallback
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.lazy.LazyColumn
|
||||
import androidx.glance.appwidget.lazy.items
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.background
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.appwidget.state.getAppWidgetState
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
@@ -34,58 +47,78 @@ 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.formatCalendarTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
import de.jeanlucmakiola.calendula.widget.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.loadAgendaWidgetData
|
||||
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.toJavaDayOfWeek
|
||||
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")
|
||||
|
||||
/**
|
||||
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
|
||||
* of events, each day one card of grouped rows with its date pill beside it.
|
||||
* of events grouped under day headers (the Google "Schedule" widget model).
|
||||
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
|
||||
*/
|
||||
class AgendaWidget : GlanceAppWidget() {
|
||||
@@ -98,47 +131,32 @@ 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 stamp = refreshStamp(context, id)
|
||||
val widgetData = WidgetData(context.loadAgendaWidget(id), stamp)
|
||||
val stored = getAppWidgetState<Preferences>(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
|
||||
provideContent {
|
||||
// A running session never re-runs this preamble, so every redraw
|
||||
// moves the stamp and the data reloads here instead (#420).
|
||||
val loaded = widgetData.current(currentRefreshStamp()) { context.loadAgendaWidget(id) }
|
||||
AgendaWidgetContent(
|
||||
data = loaded.content,
|
||||
appearance = currentState<Preferences>().widgetAppearance,
|
||||
appTheme = loaded.appTheme,
|
||||
)
|
||||
CalendulaGlanceTheme {
|
||||
AgendaWidgetBody(data = data, dark = dark)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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]).
|
||||
*/
|
||||
@Composable
|
||||
internal fun AgendaWidgetContent(
|
||||
data: AgendaWidgetData,
|
||||
appearance: WidgetAppearance,
|
||||
appTheme: ThemeMode,
|
||||
preview: Boolean = false,
|
||||
) {
|
||||
CalendulaWidgetRoot(appearance, appTheme) {
|
||||
AgendaWidgetBody(data = data, preview = preview)
|
||||
class RefreshAgendaAction : ActionCallback {
|
||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||
AgendaWidget().update(context.applicationContext, glanceId)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -148,32 +166,57 @@ private const val PREVIEW_ROWS = 10
|
||||
*/
|
||||
private const val MAX_AGENDA_ROWS = 100
|
||||
|
||||
/** Flat row model: one [LazyColumn] item per event or placeholder, a day's first one carrying its date pill. */
|
||||
/** Flat row model so the [LazyColumn] can mix day headers and events. */
|
||||
private sealed interface 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
|
||||
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
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaWidgetBody(data: AgendaWidgetData, preview: Boolean) {
|
||||
// The row height is re-resolved against the system font scale so it tracks the
|
||||
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
|
||||
// sp-sized text beside it instead of drifting at large accessibility settings.
|
||||
val size = (data as? AgendaWidgetData.Ready)?.textSize ?: WidgetSize.SMALL
|
||||
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 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()
|
||||
Spacer(GlanceModifier.height(6.dp))
|
||||
AgendaHeader(metrics)
|
||||
Spacer(GlanceModifier.height(4.dp))
|
||||
when (data) {
|
||||
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics)
|
||||
is AgendaWidgetData.Ready -> AgendaRows(data = data, metrics = metrics, preview = preview)
|
||||
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<AgendaWidgetData.Ready>(data) }
|
||||
// A range wider than what was loaded reloads in a live session,
|
||||
// where provideGlance doesn't run again (#363).
|
||||
LaunchedEffect(range) {
|
||||
val end = loaded.today.plus(
|
||||
range.dayCount(loaded.today, loaded.weekStart) - 1,
|
||||
DateTimeUnit.DAY,
|
||||
)
|
||||
if (end > loaded.loadedThrough) {
|
||||
(context.loadAgendaWidgetData(range) as? AgendaWidgetData.Ready)
|
||||
?.let { loaded = it }
|
||||
}
|
||||
}
|
||||
AgendaRows(data = loaded, range = range, dark = dark, metrics = metrics)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,16 +224,24 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, preview: Boolean) {
|
||||
@Composable
|
||||
private fun AgendaRows(
|
||||
data: AgendaWidgetData.Ready,
|
||||
range: AgendaRange,
|
||||
dark: Boolean,
|
||||
metrics: AgendaMetrics,
|
||||
preview: Boolean,
|
||||
) {
|
||||
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 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 visibleDays = anchorTodayIfMissing(
|
||||
days = data.days
|
||||
.filter { it.date <= rangeEnd }
|
||||
.let { days ->
|
||||
if (pastDisplay == PastEventDisplay.HIDE) {
|
||||
days.mapNotNull { day ->
|
||||
@@ -202,223 +253,183 @@ private fun AgendaRows(
|
||||
}
|
||||
},
|
||||
today = data.today,
|
||||
enabled = data.showToday,
|
||||
enabled = 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)) }
|
||||
}
|
||||
}
|
||||
}.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
val gap = GlanceModifier.padding(top = if (first && index > 0) metrics.dayGap else 0.dp)
|
||||
// The pills carry no month, so a new month gets its name above its first day.
|
||||
if (first && index > 0 && row.date.month != rows[index - 1].date.month) {
|
||||
Column(modifier = gap.fillMaxWidth()) {
|
||||
MonthLabel(row.date, data.today, metrics)
|
||||
dayRow(GlanceModifier)
|
||||
}
|
||||
} else {
|
||||
dayRow(gap)
|
||||
}
|
||||
}
|
||||
if (preview) {
|
||||
Column(modifier = GlanceModifier.fillMaxSize()) {
|
||||
repeat(minOf(rows.size, PREVIEW_ROWS)) { item(it) }
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||
items(rows.size) { item(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Where each row sits among the rows of its day, for the grouped-card shapes. */
|
||||
private fun dayPositions(rows: List<AgendaRow>): List<Position> = buildList {
|
||||
var start = 0
|
||||
for (end in 1..rows.size) {
|
||||
if (end == rows.size || rows[end].date != rows[start].date) {
|
||||
for (i in start until end) add(positionOf(i - start, end - start))
|
||||
start = end
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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,
|
||||
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<RefreshAgendaAction>()),
|
||||
metrics = metrics,
|
||||
)
|
||||
IconButton(
|
||||
resId = R.drawable.ic_widget_add,
|
||||
contentDescription = context.getString(R.string.widget_new_event),
|
||||
onClick = GlanceModifier.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openCreateIntent(context, today(systemZone())),
|
||||
),
|
||||
),
|
||||
metrics = metrics,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
private fun IconButton(
|
||||
resId: Int,
|
||||
contentDescription: String,
|
||||
onClick: GlanceModifier,
|
||||
metrics: AgendaMetrics,
|
||||
) {
|
||||
Box(
|
||||
modifier = GlanceModifier.size(metrics.iconBox).then(onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
WidgetHeaderAction(
|
||||
icon = ImageProvider(R.drawable.ic_widget_add),
|
||||
contentDescription = context.getString(R.string.widget_new_event),
|
||||
action = actionStartActivity(MainActivity.openCreateIntent(context, today(systemZone()))),
|
||||
Image(
|
||||
provider = ImageProvider(resId),
|
||||
contentDescription = contentDescription,
|
||||
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
|
||||
modifier = GlanceModifier.size(metrics.iconImage),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The month name ("November", with its year outside the current one) above a new month's first day. */
|
||||
@Composable
|
||||
private fun MonthLabel(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) {
|
||||
private fun DayHeaderRow(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
Text(
|
||||
text = monthName(date, today),
|
||||
maxLines = 1,
|
||||
text = agendaDayLabel(context, date, today),
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = metrics.monthLabel,
|
||||
color = if (date == today) GlanceTheme.colors.primary
|
||||
else GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = metrics.dayHeader,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
modifier = GlanceModifier
|
||||
.padding(bottom = 4.dp)
|
||||
.wallpaperBacking(radius = 8.dp)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** 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
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.height(metrics.rowHeight)
|
||||
.padding(horizontal = ROW_H_PAD)
|
||||
.padding(
|
||||
start = 8.dp,
|
||||
end = 8.dp,
|
||||
top = metrics.dayHeaderTopPad,
|
||||
bottom = metrics.rowVPad,
|
||||
)
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
|
||||
),
|
||||
),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Text(
|
||||
text = context.getString(R.string.agenda_no_more_today),
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder),
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** An event row's light and dark fill: its calendar's container tone, faded when the row is dimmed. */
|
||||
private data class EventFill(val day: Color, val night: Color)
|
||||
|
||||
private fun AgendaRow.Event.fill(data: AgendaWidgetData.Ready): EventFill {
|
||||
val dimmed = data.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now)
|
||||
fun tone(dark: Boolean) = eventFill(event.color, dark, data.soften).let {
|
||||
if (dimmed) it.copy(alpha = EventDimAlpha) else it
|
||||
}
|
||||
return EventFill(day = tone(dark = false), night = tone(dark = true))
|
||||
/**
|
||||
* "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.
|
||||
*/
|
||||
@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),
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = TEXT_INDENT,
|
||||
end = 8.dp,
|
||||
top = 2.dp,
|
||||
bottom = metrics.rowVPad + 2.dp,
|
||||
)
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventRow(
|
||||
event: EventInstance,
|
||||
day: LocalDate,
|
||||
fill: EventFill,
|
||||
dark: Boolean,
|
||||
soften: Boolean,
|
||||
is24Hour: Boolean,
|
||||
dimmed: Boolean,
|
||||
metrics: AgendaMetrics,
|
||||
) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
// Ink is picked against the opaque fill, then carries the dim along with it.
|
||||
fun ink(fill: Color, alpha: Float) = eventInk(fill.copy(alpha = 1f), alpha * fill.alpha)
|
||||
val titleInk = themedColor(day = ink(fill.day, 1f), night = ink(fill.night, 1f))
|
||||
val timeInk = themedColor(
|
||||
day = ink(fill.day, SECONDARY_INK_ALPHA),
|
||||
night = ink(fill.night, SECONDARY_INK_ALPHA),
|
||||
)
|
||||
Column(
|
||||
// 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(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.height(metrics.rowHeight)
|
||||
.padding(horizontal = ROW_H_PAD)
|
||||
.padding(horizontal = ROW_H_PAD, vertical = metrics.rowVPad)
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openEventIntent(
|
||||
@@ -432,21 +443,30 @@ private fun EventRow(
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = titleInk,
|
||||
fontSize = metrics.eventTitle,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textDecoration = glanceDeclinedDecoration(event.isDeclined),
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = eventTimeSummary(context, event, day, is24Hour),
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = timeInk, fontSize = metrics.eventTime),
|
||||
)
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,11 +480,12 @@ 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) {
|
||||
@@ -480,23 +501,26 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
|
||||
return if (relative != null) "$relative · $formatted" else formatted
|
||||
}
|
||||
|
||||
private fun monthName(date: LocalDate, today: LocalDate): String = formatCalendarTitle(
|
||||
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, 1),
|
||||
locale = Locale.getDefault(),
|
||||
currentYear = today.year,
|
||||
skeleton = "LLLL",
|
||||
)
|
||||
|
||||
private fun weekdayShort(date: LocalDate): String =
|
||||
date.dayOfWeek.toJavaDayOfWeek().getDisplayName(java.time.format.TextStyle.SHORT, Locale.getDefault())
|
||||
|
||||
private fun eventTimeSummary(
|
||||
context: Context,
|
||||
event: EventInstance,
|
||||
day: LocalDate,
|
||||
is24Hour: Boolean,
|
||||
): String {
|
||||
val time = widgetTimeLabel(context, event, day, is24Hour)
|
||||
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 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())
|
||||
}
|
||||
|
||||
+38
-3
@@ -1,9 +1,44 @@
|
||||
package de.jeanlucmakiola.calendula.widget.agenda
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
|
||||
|
||||
/** Host-facing receiver for the agenda widget; rendering is [AgendaWidget]'s. */
|
||||
class AgendaWidgetReceiver : 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() {
|
||||
override val glanceAppWidget: GlanceAppWidget = AgendaWidget()
|
||||
|
||||
/** First agenda widget placed — start rolling "today" over at midnight (#228). */
|
||||
override fun onEnabled(context: Context) {
|
||||
super.onEnabled(context)
|
||||
WidgetRolloverScheduler.sync(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Last agenda widget removed. [WidgetRolloverScheduler.sync] cancels only if
|
||||
* no month widget is left either.
|
||||
*/
|
||||
override fun onDisabled(context: Context) {
|
||||
super.onDisabled(context)
|
||||
WidgetRolloverScheduler.sync(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-heal on the system's own `updatePeriodMillis` wake-up — see
|
||||
* [de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver.onUpdate].
|
||||
*/
|
||||
override fun onUpdate(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
appWidgetIds: IntArray,
|
||||
) {
|
||||
super.onUpdate(context, appWidgetManager, appWidgetIds)
|
||||
WidgetRolloverScheduler.sync(context)
|
||||
}
|
||||
}
|
||||
|
||||
-178
@@ -1,178 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.widget.config
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.PickerSearchField
|
||||
import de.jeanlucmakiola.calendula.widget.countdown.CountdownMode
|
||||
import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedChoiceRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
|
||||
/** What the countdown widget counts to (#439): the next event, or one picked from a searchable list. */
|
||||
@Composable
|
||||
internal fun CountdownOptionsSection(
|
||||
options: CountdownOptions,
|
||||
candidates: CountdownCandidates,
|
||||
onChange: (CountdownOptions) -> Unit,
|
||||
onLoadCandidates: () -> Unit,
|
||||
) {
|
||||
var picking by rememberSaveable { mutableStateOf(false) }
|
||||
GroupedSectionHeader(stringResource(R.string.widget_config_countdown))
|
||||
GroupedChoiceRow(
|
||||
title = stringResource(R.string.widget_config_countdown),
|
||||
options = CountdownMode.entries,
|
||||
selected = options.mode,
|
||||
label = {
|
||||
stringResource(
|
||||
when (it) {
|
||||
CountdownMode.NEXT -> R.string.widget_config_countdown_next
|
||||
CountdownMode.PINNED -> R.string.widget_config_countdown_pinned
|
||||
},
|
||||
)
|
||||
},
|
||||
onSelect = { mode ->
|
||||
onChange(options.copy(mode = mode))
|
||||
if (mode == CountdownMode.PINNED && options.eventId == null) picking = true
|
||||
},
|
||||
position = if (options.mode == CountdownMode.PINNED) Position.Top else Position.Alone,
|
||||
)
|
||||
when (options.mode) {
|
||||
CountdownMode.NEXT -> GroupedHint(stringResource(R.string.widget_config_countdown_next_hint), verticalPadding = 8.dp)
|
||||
CountdownMode.PINNED -> GroupedRow(
|
||||
title = stringResource(R.string.widget_config_countdown_event),
|
||||
summary = options.title ?: stringResource(R.string.widget_config_countdown_event_none),
|
||||
position = Position.Bottom,
|
||||
onClick = { picking = true },
|
||||
)
|
||||
}
|
||||
if (picking) {
|
||||
LaunchedEffect(Unit) { onLoadCandidates() }
|
||||
CountdownEventPicker(
|
||||
candidates = candidates,
|
||||
selectedEventId = options.eventId,
|
||||
onRetry = onLoadCandidates,
|
||||
onSelect = { candidate ->
|
||||
onChange(options.copy(mode = CountdownMode.PINNED, eventId = candidate.event.eventId, title = candidate.event.title))
|
||||
picking = false
|
||||
},
|
||||
onDismiss = { picking = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CountdownEventPicker(
|
||||
candidates: CountdownCandidates,
|
||||
selectedEventId: Long?,
|
||||
onRetry: () -> Unit,
|
||||
onSelect: (CountdownCandidate) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.widget_config_countdown_event),
|
||||
onDismiss = onDismiss,
|
||||
predictiveBack = true,
|
||||
scrollable = false,
|
||||
) {
|
||||
PickerSearchField(
|
||||
query = query,
|
||||
onQueryChange = { query = it },
|
||||
placeholder = stringResource(R.string.widget_config_countdown_search),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
when (candidates) {
|
||||
CountdownCandidates.Idle, CountdownCandidates.Loading -> Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { CircularProgressIndicator() }
|
||||
CountdownCandidates.Failed -> Column(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
PickerMessage(stringResource(R.string.widget_config_countdown_load_failed))
|
||||
FilledTonalButton(onClick = onRetry, modifier = Modifier.padding(top = 16.dp)) {
|
||||
Text(stringResource(R.string.state_retry))
|
||||
}
|
||||
}
|
||||
is CountdownCandidates.Ready -> {
|
||||
val locale = currentLocale()
|
||||
val matches = remember(candidates, query) {
|
||||
val needle = query.trim()
|
||||
if (needle.isEmpty()) candidates.events
|
||||
else candidates.events.filter { it.event.title.contains(needle, ignoreCase = true) }
|
||||
}
|
||||
if (matches.isEmpty()) {
|
||||
PickerMessage(
|
||||
stringResource(R.string.widget_config_countdown_empty),
|
||||
Modifier.fillMaxWidth().padding(24.dp),
|
||||
)
|
||||
} else {
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val formatter = remember(locale) { localizedDateFormatter(locale, "EEEdMMMy") }
|
||||
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) {
|
||||
itemsIndexed(matches, key = { _, it -> it.event.eventId }) { index, candidate ->
|
||||
val date = formatter.format(candidate.event.start.toLocalDateTime(zone).date.toJavaLocalDate())
|
||||
GroupedRow(
|
||||
title = candidate.event.title.ifBlank { stringResource(R.string.event_untitled) },
|
||||
summary = if (candidate.repeats) stringResource(R.string.widget_config_countdown_repeats, date) else date,
|
||||
position = positionOf(index, matches.size),
|
||||
selected = candidate.event.eventId == selectedEventId,
|
||||
minHeight = 56.dp,
|
||||
leading = { CalendarColorChip(candidate.event.color) },
|
||||
onClick = { onSelect(candidate) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PickerMessage(text: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.widget.config
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.widgetState
|
||||
|
||||
/** One of the app's widgets on the home screen, for the settings list. */
|
||||
data class PlacedWidget(val appWidgetId: Int, val kind: WidgetKind, val appearance: WidgetAppearance)
|
||||
|
||||
/**
|
||||
* Every placed widget, agenda ones first, each in placement order. An id the
|
||||
* host bound but never laid out (a placement it abandoned without freeing the
|
||||
* id) has no size yet and is left out, as is one removed while this reads.
|
||||
*/
|
||||
suspend fun Context.placedWidgets(): List<PlacedWidget> {
|
||||
val manager = AppWidgetManager.getInstance(this) ?: return emptyList()
|
||||
val glanceManager = GlanceAppWidgetManager(this)
|
||||
return WidgetKind.entries.flatMap { kind ->
|
||||
manager.getAppWidgetIds(ComponentName(this, kind.receiver)).sorted().mapNotNull { id ->
|
||||
if (manager.getAppWidgetOptions(id).getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) <= 0) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
val glanceId = runCatching { glanceManager.getGlanceIdBy(id) }.getOrNull() ?: return@mapNotNull null
|
||||
PlacedWidget(id, kind, widgetState(glanceId).widgetAppearance)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.widget.config
|
||||
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
||||
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
|
||||
/** Which of the app's calendars one widget shows, grouped by account like the drawer's filter. */
|
||||
@Composable
|
||||
internal fun WidgetCalendarPicker(
|
||||
calendars: List<CalendarSource>,
|
||||
hidden: Set<Long>,
|
||||
onSetShown: (Long, Boolean) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.widget_config_calendars),
|
||||
onDismiss = onDismiss,
|
||||
predictiveBack = true,
|
||||
) {
|
||||
GroupedHint(stringResource(R.string.widget_config_calendars_hint), verticalPadding = 8.dp)
|
||||
calendars.groupByAccount().forEach { group ->
|
||||
GroupedSectionHeader(accountGroupTitle(group))
|
||||
group.calendars.forEachIndexed { index, calendar ->
|
||||
val shown = calendar.id !in hidden
|
||||
GroupedRow(
|
||||
title = calendar.displayName.orUnnamed(),
|
||||
position = positionOf(index, group.calendars.size),
|
||||
minHeight = 56.dp,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = { Checkbox(checked = shown, onCheckedChange = { onSetShown(calendar.id, it) }) },
|
||||
onClick = { onSetShown(calendar.id, !shown) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "All calendars", or how many of them the widget shows. */
|
||||
@Composable
|
||||
internal fun calendarsSummary(calendars: List<CalendarSource>, hidden: Set<Long>): String {
|
||||
val shown = calendars.count { it.id !in hidden }
|
||||
return if (shown == calendars.size) {
|
||||
stringResource(R.string.widget_config_calendars_all)
|
||||
} else {
|
||||
pluralStringResource(R.plurals.widget_config_calendars_some, calendars.size, shown, calendars.size)
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,396 +0,0 @@
|
||||
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
|
||||
},
|
||||
)
|
||||
@@ -1,335 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.widget.config
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||
import de.jeanlucmakiola.calendula.widget.DaysWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSettings
|
||||
import de.jeanlucmakiola.calendula.widget.loadDays
|
||||
import de.jeanlucmakiola.calendula.widget.weekOf
|
||||
import de.jeanlucmakiola.calendula.widget.upcoming.loadUpcoming
|
||||
import de.jeanlucmakiola.calendula.widget.today.loadTodayList
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.widget.countdown.CountdownData
|
||||
import de.jeanlucmakiola.calendula.widget.countdown.CountdownOptions
|
||||
import de.jeanlucmakiola.calendula.widget.countdown.countdownOptions
|
||||
import de.jeanlucmakiola.calendula.widget.countdown.loadCountdown
|
||||
import de.jeanlucmakiola.calendula.widget.countdown.putCountdownOptions
|
||||
import de.jeanlucmakiola.calendula.widget.split.MonthDayData
|
||||
import de.jeanlucmakiola.calendula.widget.split.loadMonthDay
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetOption
|
||||
import de.jeanlucmakiola.calendula.widget.hiddenCalendars
|
||||
import de.jeanlucmakiola.calendula.widget.putHiddenCalendars
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
|
||||
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.loadThemedMonthSource
|
||||
import de.jeanlucmakiola.calendula.widget.monthIndexOf
|
||||
import de.jeanlucmakiola.calendula.widget.monthWindowCenter
|
||||
import de.jeanlucmakiola.calendula.widget.agendaWidgetOptions
|
||||
import de.jeanlucmakiola.calendula.widget.putAgendaOptions
|
||||
import de.jeanlucmakiola.calendula.widget.putAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||
import de.jeanlucmakiola.calendula.widget.today
|
||||
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.widgetSettings
|
||||
import de.jeanlucmakiola.calendula.widget.widgetState
|
||||
import de.jeanlucmakiola.floret.glance.updateWithRefresh
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import java.util.Locale
|
||||
|
||||
/** What the preview draws, loaded with the draft's options. */
|
||||
sealed interface WidgetPreviewData {
|
||||
data class Agenda(val data: AgendaWidgetData) : WidgetPreviewData
|
||||
data class Month(val source: MonthWidgetSource, val index: Int) : WidgetPreviewData
|
||||
data class Today(val today: LocalDate) : WidgetPreviewData
|
||||
data class Days(val data: DaysWidgetData) : WidgetPreviewData
|
||||
data class Countdown(val data: CountdownData) : WidgetPreviewData
|
||||
data class MonthDay(val data: MonthDayData, val index: Int) : WidgetPreviewData
|
||||
}
|
||||
|
||||
/** The options being edited, applied to the preview as they change and written on save. */
|
||||
data class WidgetDraft(
|
||||
val appearance: WidgetAppearance,
|
||||
/** The content options; stored only for a kind that [WidgetKind.hasContentOptions]. */
|
||||
val content: AgendaWidgetOptions,
|
||||
val hiddenCalendars: Set<Long>,
|
||||
val countdown: CountdownOptions = CountdownOptions(),
|
||||
)
|
||||
|
||||
/** One event or series the countdown can be pinned to, at its next occurrence. */
|
||||
data class CountdownCandidate(val event: EventInstance, val repeats: Boolean)
|
||||
|
||||
/** The countdown's event list, loaded when its picker first opens. */
|
||||
sealed interface CountdownCandidates {
|
||||
data object Idle : CountdownCandidates
|
||||
data object Loading : CountdownCandidates
|
||||
data object Failed : CountdownCandidates
|
||||
data class Ready(val events: List<CountdownCandidate>) : CountdownCandidates
|
||||
}
|
||||
|
||||
private fun WidgetDraft.loadContext(settings: WidgetSettings) = WidgetLoadContext(settings, content, hiddenCalendars)
|
||||
|
||||
sealed interface WidgetConfigUiState {
|
||||
data object Loading : WidgetConfigUiState
|
||||
|
||||
/** The widget is gone, or isn't one of ours; the screen closes. */
|
||||
data object Missing : WidgetConfigUiState
|
||||
|
||||
/** Its stored options couldn't be read. */
|
||||
data object Failed : WidgetConfigUiState
|
||||
|
||||
data class Ready(
|
||||
val kind: WidgetKind,
|
||||
val draft: WidgetDraft,
|
||||
/** The calendars the app shows, for the widget's own calendar choice. */
|
||||
val calendars: List<CalendarSource>,
|
||||
val appTheme: ThemeMode,
|
||||
val weekStart: DayOfWeek,
|
||||
/** The widget's size on the home screen, which the preview is laid out at. */
|
||||
val placedSize: DpSize,
|
||||
/** Null until the first preview load lands. */
|
||||
val preview: WidgetPreviewData?,
|
||||
val candidates: CountdownCandidates = CountdownCandidates.Idle,
|
||||
/** The first preview load failed, so there's nothing to show. */
|
||||
val previewFailed: Boolean = false,
|
||||
val saving: Boolean = false,
|
||||
) : WidgetConfigUiState {
|
||||
val appearance: WidgetAppearance get() = draft.appearance
|
||||
}
|
||||
}
|
||||
|
||||
/** One-time outcomes of a save, for the screen that's showing when they land. */
|
||||
enum class WidgetConfigEvent { SAVED, SAVE_FAILED }
|
||||
|
||||
/**
|
||||
* Backs [WidgetConfigActivity] (#292): the placed widget's stored options as an
|
||||
* editable draft, a preview loaded with that draft, and the save that writes it
|
||||
* into the widget's Glance state and redraws it.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class WidgetConfigViewModel @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val prefs: SettingsPrefs,
|
||||
private val calendarPrefs: CalendarPrefs,
|
||||
private val repository: CalendarRepository,
|
||||
savedState: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
val appWidgetId: Int = savedState[AppWidgetManager.EXTRA_APPWIDGET_ID] ?: AppWidgetManager.INVALID_APPWIDGET_ID
|
||||
|
||||
private val _state = MutableStateFlow<WidgetConfigUiState>(WidgetConfigUiState.Loading)
|
||||
val state: StateFlow<WidgetConfigUiState> = _state.asStateFlow()
|
||||
|
||||
private val _events = Channel<WidgetConfigEvent>(Channel.BUFFERED)
|
||||
val events: Flow<WidgetConfigEvent> = _events.receiveAsFlow()
|
||||
|
||||
private var previewJob: Job? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch { load() }
|
||||
}
|
||||
|
||||
fun retry() {
|
||||
if (_state.value != WidgetConfigUiState.Failed) return
|
||||
_state.value = WidgetConfigUiState.Loading
|
||||
viewModelScope.launch { load() }
|
||||
}
|
||||
|
||||
private suspend fun load() {
|
||||
val kind = WidgetKind.of(context, appWidgetId)
|
||||
if (kind == null) {
|
||||
_state.value = WidgetConfigUiState.Missing
|
||||
return
|
||||
}
|
||||
_state.value = try {
|
||||
val stored = context.widgetState(GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId))
|
||||
val settings = prefs.widgetSettings.first()
|
||||
WidgetConfigUiState.Ready(
|
||||
kind = kind,
|
||||
draft = WidgetDraft(
|
||||
appearance = stored.widgetAppearance,
|
||||
content = stored.agendaWidgetOptions(settings.agendaDefaults),
|
||||
hiddenCalendars = stored.hiddenCalendars,
|
||||
countdown = stored.countdownOptions,
|
||||
),
|
||||
calendars = if (WidgetOption.CALENDARS in kind.options) shownCalendars() else emptyList(),
|
||||
appTheme = settings.themeMode,
|
||||
weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()),
|
||||
placedSize = placedSize(kind),
|
||||
preview = null,
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: IllegalArgumentException) {
|
||||
// getGlanceIdBy: the widget was removed meanwhile.
|
||||
WidgetConfigUiState.Missing
|
||||
} catch (_: Exception) {
|
||||
WidgetConfigUiState.Failed
|
||||
}
|
||||
reloadPreview()
|
||||
}
|
||||
|
||||
/** The size the launcher placed the widget at (portrait), or a default before it has reported one. */
|
||||
private fun placedSize(kind: WidgetKind): DpSize {
|
||||
val options = AppWidgetManager.getInstance(context)?.getAppWidgetOptions(appWidgetId)
|
||||
val width = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) ?: 0
|
||||
val height = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT) ?: 0
|
||||
if (width > 0 && height > 0) return DpSize(width.dp, height.dp)
|
||||
return kind.defaultSize
|
||||
}
|
||||
|
||||
/** The calendars the app itself shows; the ones hidden there never reach a widget anyway. */
|
||||
private suspend fun shownCalendars(): List<CalendarSource> {
|
||||
val hidden = calendarPrefs.hiddenCalendarIds.first()
|
||||
return repository.calendars().first().filter { it.isVisibleInSystem && it.id !in hidden }
|
||||
}
|
||||
|
||||
fun setAppearance(appearance: WidgetAppearance) = edit { it.copy(draft = it.draft.copy(appearance = appearance)) }
|
||||
|
||||
fun setContent(options: AgendaWidgetOptions) {
|
||||
edit { it.copy(draft = it.draft.copy(content = options)) }
|
||||
reloadPreview()
|
||||
}
|
||||
|
||||
fun setCountdown(options: CountdownOptions) {
|
||||
edit { it.copy(draft = it.draft.copy(countdown = options)) }
|
||||
reloadPreview()
|
||||
}
|
||||
|
||||
/** Loads the events the countdown can be pinned to, unless they're loaded or loading. */
|
||||
fun loadCountdownCandidates() {
|
||||
val ready = _state.value as? WidgetConfigUiState.Ready ?: return
|
||||
if (ready.candidates is CountdownCandidates.Loading || ready.candidates is CountdownCandidates.Ready) return
|
||||
edit { it.copy(candidates = CountdownCandidates.Loading) }
|
||||
viewModelScope.launch {
|
||||
val candidates = try {
|
||||
val now = Clock.System.now()
|
||||
val occurrences = repository.instances(now..now + 365.days).first().filter { it.end > now }
|
||||
val perEvent = occurrences.groupBy { it.eventId }
|
||||
CountdownCandidates.Ready(
|
||||
perEvent.values
|
||||
.map { CountdownCandidate(it.minBy { o -> o.start }, repeats = it.size > 1) }
|
||||
.sortedBy { it.event.start },
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
CountdownCandidates.Failed
|
||||
}
|
||||
edit { it.copy(candidates = candidates) }
|
||||
}
|
||||
}
|
||||
|
||||
fun setCalendarShown(id: Long, shown: Boolean) {
|
||||
edit {
|
||||
val hidden = it.draft.hiddenCalendars
|
||||
it.copy(draft = it.draft.copy(hiddenCalendars = if (shown) hidden - id else hidden + id))
|
||||
}
|
||||
reloadPreview()
|
||||
}
|
||||
|
||||
private fun edit(change: (WidgetConfigUiState.Ready) -> WidgetConfigUiState.Ready) {
|
||||
_state.update { if (it is WidgetConfigUiState.Ready) change(it) else it }
|
||||
}
|
||||
|
||||
private fun reloadPreview() {
|
||||
val ready = _state.value as? WidgetConfigUiState.Ready ?: return
|
||||
previewJob?.cancel()
|
||||
previewJob = viewModelScope.launch {
|
||||
val preview = try {
|
||||
loadPreview(ready)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
// The options stay editable; the preview keeps what it had.
|
||||
edit { it.copy(previewFailed = it.preview == null) }
|
||||
return@launch
|
||||
}
|
||||
edit { it.copy(preview = preview, previewFailed = false) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadPreview(ready: WidgetConfigUiState.Ready): WidgetPreviewData {
|
||||
val draft = ready.draft
|
||||
val settings = prefs.widgetSettings.first()
|
||||
return when (ready.kind) {
|
||||
WidgetKind.AGENDA ->
|
||||
WidgetPreviewData.Agenda(context.loadAgendaWidgetData(settings, draft.content, draft.hiddenCalendars))
|
||||
WidgetKind.MONTH -> {
|
||||
val index = monthIndexOf(today(systemZone()))
|
||||
val source = context.loadThemedMonthSource(monthWindowCenter(index), draft.hiddenCalendars)
|
||||
WidgetPreviewData.Month(source.content, index)
|
||||
}
|
||||
WidgetKind.QUICK_ADD, WidgetKind.DATE -> WidgetPreviewData.Today(today(systemZone()))
|
||||
WidgetKind.UPCOMING -> WidgetPreviewData.Days(context.loadUpcoming(draft.loadContext(settings)).content)
|
||||
WidgetKind.TODAY -> WidgetPreviewData.Days(context.loadTodayList(draft.loadContext(settings)).content)
|
||||
WidgetKind.MONTH_DAY -> {
|
||||
val index = monthIndexOf(today(systemZone()))
|
||||
val source = context.loadThemedMonthSource(monthWindowCenter(index), draft.hiddenCalendars)
|
||||
WidgetPreviewData.MonthDay(context.loadMonthDay(source, draft.content).content, index)
|
||||
}
|
||||
WidgetKind.COUNTDOWN ->
|
||||
WidgetPreviewData.Countdown(context.loadCountdown(draft.loadContext(settings), draft.countdown, null).content)
|
||||
WidgetKind.WEEK_STRIP -> WidgetPreviewData.Days(context.loadDays(draft.loadContext(settings), ::weekOf).content)
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes the draft into the widget and redraws it, then reports how that went on [events]. */
|
||||
fun save() {
|
||||
val ready = _state.value as? WidgetConfigUiState.Ready ?: return
|
||||
if (ready.saving) return
|
||||
edit { it.copy(saving = true) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId)
|
||||
ready.kind.widget().updateWithRefresh(context, glanceId) { state ->
|
||||
state.putAppearance(ready.draft.appearance)
|
||||
if (ready.kind.hasContentOptions) state.putAgendaOptions(ready.draft.content)
|
||||
if (WidgetOption.CALENDARS in ready.kind.options) state.putHiddenCalendars(ready.draft.hiddenCalendars)
|
||||
if (ready.kind == WidgetKind.COUNTDOWN) state.putCountdownOptions(ready.draft.countdown)
|
||||
}
|
||||
_events.send(WidgetConfigEvent.SAVED)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: IllegalArgumentException) {
|
||||
_state.value = WidgetConfigUiState.Missing
|
||||
} catch (_: Exception) {
|
||||
edit { it.copy(saving = false) }
|
||||
_events.send(WidgetConfigEvent.SAVE_FAILED)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.widget.countdown
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.daysUntil
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** What a countdown widget counts down to (#439). */
|
||||
enum class CountdownMode { NEXT, PINNED }
|
||||
|
||||
data class CountdownOptions(
|
||||
val mode: CountdownMode = CountdownMode.NEXT,
|
||||
/** The pinned event or series; a series follows its next occurrence. */
|
||||
val eventId: Long? = null,
|
||||
/** The pinned event's title when it was chosen, for the config screen. */
|
||||
val title: String? = null,
|
||||
)
|
||||
|
||||
enum class CountdownUnit { NOW, MINUTES, HOURS, DAYS }
|
||||
|
||||
/** "in 25 min", "ends in 3 h", "in 12 days": [running] counts to the end instead of the start. */
|
||||
data class CountdownLabel(val running: Boolean, val unit: CountdownUnit, val amount: Int)
|
||||
|
||||
private fun target(event: EventInstance, now: Instant): Instant = if (event.start <= now) event.end else event.start
|
||||
|
||||
/**
|
||||
* The label for [event] at [now]. Under a minute reads "now", under an hour
|
||||
* counts minutes (rounded up), under a day whole hours (rounded), and beyond
|
||||
* that calendar days in [zone], so "in 1 day" is always tomorrow.
|
||||
*/
|
||||
fun countdownLabel(event: EventInstance, now: Instant, zone: TimeZone): CountdownLabel {
|
||||
val running = event.start <= now
|
||||
val target = target(event, now)
|
||||
val delta = target - now
|
||||
return when {
|
||||
delta < 1.minutes -> CountdownLabel(running, CountdownUnit.NOW, 0)
|
||||
delta < 1.hours -> CountdownLabel(running, CountdownUnit.MINUTES, ceil(delta / 1.minutes).toInt())
|
||||
delta < 24.hours -> CountdownLabel(running, CountdownUnit.HOURS, (delta / 1.hours).roundToInt().coerceAtLeast(1))
|
||||
else -> {
|
||||
val days = now.toLocalDateTime(zone).date.daysUntil(target.toLocalDateTime(zone).date)
|
||||
CountdownLabel(running, CountdownUnit.DAYS, days.coerceAtLeast(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When [countdownLabel] next reads differently, so the widget redraws then
|
||||
* and not before: every minute in the last hour, at each half hour within the
|
||||
* day, and at midnight or the 24-hour mark beyond that.
|
||||
*/
|
||||
fun nextLabelChange(event: EventInstance, now: Instant, zone: TimeZone): Instant {
|
||||
val target = target(event, now)
|
||||
val label = countdownLabel(event, now, zone)
|
||||
val lowerBound: Duration = when (label.unit) {
|
||||
CountdownUnit.NOW -> Duration.ZERO
|
||||
CountdownUnit.MINUTES -> (label.amount - 1).minutes
|
||||
CountdownUnit.HOURS -> maxOf((label.amount - 0.5).hours, 1.hours)
|
||||
CountdownUnit.DAYS -> {
|
||||
val midnight = now.toLocalDateTime(zone).date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
|
||||
return minOf(midnight, target - 24.hours).coerceAtLeast(now + 1.minutes)
|
||||
}
|
||||
}
|
||||
return (target - lowerBound).coerceAtLeast(now + 1.minutes)
|
||||
}
|
||||
|
||||
/**
|
||||
* The next-event mode's pick: timed, not declined, not over; of those, the one
|
||||
* whose countdown changes soonest — a running event's end, else a start.
|
||||
*/
|
||||
fun pickNextEvent(instances: List<EventInstance>, now: Instant): EventInstance? =
|
||||
instances
|
||||
.filter { !it.isAllDay && !it.isDeclined && it.end > now }
|
||||
.minByOrNull { target(it, now) }
|
||||
|
||||
/** A pinned event's occurrence to count to: the running or next one. */
|
||||
fun pickPinnedOccurrence(occurrences: List<EventInstance>, now: Instant, zone: TimeZone): EventInstance? =
|
||||
occurrences.filter { it.counted(zone).end > now }.minByOrNull { it.start }
|
||||
|
||||
/**
|
||||
* The span a countdown counts over. An all-day event is stored on UTC
|
||||
* midnights; it starts and ends at local midnight of those dates instead.
|
||||
*/
|
||||
fun EventInstance.counted(zone: TimeZone): EventInstance = if (!isAllDay) this else copy(
|
||||
start = start.toLocalDateTime(TimeZone.UTC).date.atStartOfDayIn(zone),
|
||||
end = end.toLocalDateTime(TimeZone.UTC).date.atStartOfDayIn(zone),
|
||||
)
|
||||
@@ -1,167 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.widget.countdown
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.net.toUri
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.widget.Themed
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
|
||||
import de.jeanlucmakiola.calendula.widget.hasCalendarPermission
|
||||
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||
import de.jeanlucmakiola.calendula.widget.today
|
||||
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
|
||||
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
|
||||
import de.jeanlucmakiola.calendula.widget.widgetState
|
||||
import de.jeanlucmakiola.calendula.widget.withoutCalendars
|
||||
import de.jeanlucmakiola.floret.android.goAsync
|
||||
import de.jeanlucmakiola.floret.glance.updateWithRefresh
|
||||
import de.jeanlucmakiola.floret.prefs.toEnum
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Instant
|
||||
|
||||
private val ModeKey = stringPreferencesKey("cfg_countdown_mode")
|
||||
private val EventKey = longPreferencesKey("cfg_countdown_event")
|
||||
private val TitleKey = stringPreferencesKey("cfg_countdown_title")
|
||||
|
||||
internal val Preferences.countdownOptions: CountdownOptions
|
||||
get() = CountdownOptions(this[ModeKey].toEnum(CountdownMode.NEXT), this[EventKey], this[TitleKey])
|
||||
|
||||
internal fun MutablePreferences.putCountdownOptions(options: CountdownOptions) {
|
||||
this[ModeKey] = options.mode.name
|
||||
if (options.eventId != null) this[EventKey] = options.eventId else remove(EventKey)
|
||||
if (options.title != null) this[TitleKey] = options.title else remove(TitleKey)
|
||||
}
|
||||
|
||||
/** How far ahead the next-event mode looks; past this it shows no event. */
|
||||
private val NextEventHorizon = 30.days
|
||||
|
||||
/** How far ahead a pinned event's next occurrence is looked for. */
|
||||
private val PinnedHorizon = 730.days
|
||||
|
||||
sealed interface CountdownData {
|
||||
data object NeedsPermission : CountdownData
|
||||
data class Ready(
|
||||
val options: CountdownOptions,
|
||||
/** The occurrence counted to; null when there is none to show. */
|
||||
val event: EventInstance?,
|
||||
val now: Instant,
|
||||
val today: LocalDate,
|
||||
val is24Hour: Boolean,
|
||||
val soften: Boolean,
|
||||
/** The placed widget, for opening its settings; null in a preview. */
|
||||
val appWidgetId: Int?,
|
||||
) : CountdownData
|
||||
}
|
||||
|
||||
internal suspend fun Context.loadCountdown(
|
||||
load: WidgetLoadContext,
|
||||
options: CountdownOptions,
|
||||
appWidgetId: Int?,
|
||||
): Themed<CountdownData> {
|
||||
val settings = load.settings
|
||||
if (!hasCalendarPermission()) return Themed(CountdownData.NeedsPermission, settings.themeMode)
|
||||
val now = Clock.System.now()
|
||||
val repository = widgetEntryPoint().calendarRepository()
|
||||
val event = when (options.mode) {
|
||||
CountdownMode.NEXT ->
|
||||
pickNextEvent(repository.instances(now..now + NextEventHorizon).first().withoutCalendars(load.hiddenCalendars), now)
|
||||
CountdownMode.PINNED -> options.eventId?.let { id ->
|
||||
pickPinnedOccurrence(repository.eventOccurrences(id, now - 1.days..now + PinnedHorizon), now, systemZone())
|
||||
}
|
||||
}
|
||||
return Themed(
|
||||
CountdownData.Ready(
|
||||
options = options,
|
||||
event = event,
|
||||
now = now,
|
||||
today = today(systemZone()),
|
||||
is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)),
|
||||
soften = settings.soften,
|
||||
appWidgetId = appWidgetId,
|
||||
),
|
||||
settings.themeMode,
|
||||
)
|
||||
}
|
||||
|
||||
/** [loadCountdown] for placed widget [id], arming its redraw for when the label next changes. */
|
||||
internal suspend fun Context.loadPlacedCountdown(id: GlanceId): Themed<CountdownData> {
|
||||
val appWidgetId = GlanceAppWidgetManager(this).getAppWidgetId(id)
|
||||
val loaded = loadCountdown(widgetLoadContext(id), widgetState(id).countdownOptions, appWidgetId)
|
||||
val event = (loaded.content as? CountdownData.Ready)?.event
|
||||
if (event == null) {
|
||||
CountdownTicks.cancel(this, appWidgetId)
|
||||
} else {
|
||||
val zone = systemZone()
|
||||
CountdownTicks.schedule(this, appWidgetId, nextLabelChange(event.counted(zone), (loaded.content as CountdownData.Ready).now, zone))
|
||||
}
|
||||
return loaded
|
||||
}
|
||||
|
||||
/**
|
||||
* One alarm per countdown widget, for the moment its label changes. Not a
|
||||
* wake-up alarm: with the screen off nobody sees the widget, so the redraw
|
||||
* waits for the device to wake. Exact where allowed (the app holds the exact
|
||||
* alarm permission for reminders), else within a minute.
|
||||
*/
|
||||
internal object CountdownTicks {
|
||||
private const val ACTION_TICK = "de.jeanlucmakiola.calendula.widget.COUNTDOWN_TICK"
|
||||
|
||||
fun schedule(context: Context, appWidgetId: Int, at: Instant) {
|
||||
val alarms = context.getSystemService(AlarmManager::class.java) ?: return
|
||||
val intent = pendingIntent(context, appWidgetId)
|
||||
val millis = at.toEpochMilliseconds()
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarms.canScheduleExactAlarms()) {
|
||||
alarms.setExact(AlarmManager.RTC, millis, intent)
|
||||
} else {
|
||||
alarms.setWindow(AlarmManager.RTC, millis, 60_000L, intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel(context: Context, appWidgetId: Int) {
|
||||
context.getSystemService(AlarmManager::class.java)?.cancel(pendingIntent(context, appWidgetId))
|
||||
}
|
||||
|
||||
private fun pendingIntent(context: Context, appWidgetId: Int): PendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
0,
|
||||
Intent(context, CountdownTickReceiver::class.java)
|
||||
.setAction(ACTION_TICK)
|
||||
// Keys the PendingIntent per widget.
|
||||
.setData("calendula://countdown/$appWidgetId".toUri())
|
||||
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
|
||||
/** Reloads the countdown widget whose label just changed; the load arms the next tick. */
|
||||
class CountdownTickReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
|
||||
if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) return
|
||||
val appContext = context.applicationContext
|
||||
goAsync {
|
||||
try {
|
||||
val glanceId = GlanceAppWidgetManager(appContext).getGlanceIdBy(appWidgetId)
|
||||
CountdownWidget().updateWithRefresh(appContext, glanceId)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
// The widget is gone.
|
||||
CountdownTicks.cancel(appContext, appWidgetId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.widget.countdown
|
||||
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.action.Action
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.background
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxHeight
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.config.WidgetConfigActivity
|
||||
import de.jeanlucmakiola.calendula.widget.formatWidgetTime
|
||||
import de.jeanlucmakiola.calendula.widget.relativeDayLabel
|
||||
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.widgetCardColor
|
||||
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetData
|
||||
import de.jeanlucmakiola.floret.glance.WidgetMessage
|
||||
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
|
||||
import de.jeanlucmakiola.floret.glance.refreshStamp
|
||||
import de.jeanlucmakiola.floret.glance.themedColor
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
|
||||
/**
|
||||
* Countdown (#439): to the next timed event, or to a chosen event or series
|
||||
* ("Vacation · in 12 days"). Redraws when its label changes, see
|
||||
* [nextLabelChange] and [CountdownTicks]. Tapping opens the event.
|
||||
*/
|
||||
class CountdownWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
|
||||
override val sizeMode = SizeMode.Single
|
||||
|
||||
override val previewSizeMode = previewSizeOf(WidgetKind.COUNTDOWN)
|
||||
|
||||
override suspend fun providePreview(context: Context, widgetCategory: Int) =
|
||||
provideSamplePreview(context, WidgetKind.COUNTDOWN)
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val widgetData = WidgetData(context.loadPlacedCountdown(id), refreshStamp(context, id))
|
||||
provideContent {
|
||||
val loaded = widgetData.current(currentRefreshStamp()) { context.loadPlacedCountdown(id) }
|
||||
CountdownWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CountdownWidgetReceiver : CalendulaWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = CountdownWidget()
|
||||
|
||||
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
|
||||
super.onDeleted(context, appWidgetIds)
|
||||
appWidgetIds.forEach { CountdownTicks.cancel(context, it) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun CountdownWidgetContent(data: CountdownData, appearance: WidgetAppearance, appTheme: ThemeMode) {
|
||||
CalendulaWidgetRoot(appearance, appTheme) {
|
||||
val context = LocalContext.current
|
||||
when (data) {
|
||||
CountdownData.NeedsPermission -> WidgetMessage(context.getString(R.string.widget_needs_permission))
|
||||
is CountdownData.Ready -> {
|
||||
val event = data.event
|
||||
when {
|
||||
event != null -> EventCountdown(event, data)
|
||||
data.options.mode == CountdownMode.PINNED && data.options.eventId == null ->
|
||||
Message(context.getString(R.string.widget_countdown_choose), configAction(context, data.appWidgetId))
|
||||
data.options.mode == CountdownMode.PINNED ->
|
||||
Message(context.getString(R.string.widget_countdown_over, data.options.title.orEmpty()), configAction(context, data.appWidgetId))
|
||||
else -> Message(
|
||||
context.getString(R.string.widget_upcoming_empty),
|
||||
actionStartActivity(MainActivity.openViewIntent(context, null)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun configAction(context: Context, appWidgetId: Int?): Action? =
|
||||
appWidgetId?.takeIf { it != AppWidgetManager.INVALID_APPWIDGET_ID }
|
||||
?.let { actionStartActivity(WidgetConfigActivity.intent(context, it)) }
|
||||
|
||||
@Composable
|
||||
private fun Message(text: String, action: Action?) {
|
||||
val modifier = if (action != null) GlanceModifier.clickable(action) else GlanceModifier
|
||||
WidgetMessage(text, modifier = modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventCountdown(event: EventInstance, data: CountdownData.Ready) {
|
||||
val context = LocalContext.current
|
||||
val label = countdownLabel(event.counted(systemZone()), data.now, systemZone())
|
||||
val open = actionStartActivity(
|
||||
MainActivity.openEventIntent(
|
||||
context = context,
|
||||
eventId = event.eventId,
|
||||
beginMillis = event.start.toEpochMilliseconds(),
|
||||
endMillis = event.end.toEpochMilliseconds(),
|
||||
source = CalendarView.Agenda,
|
||||
),
|
||||
)
|
||||
// Over a wallpaper the text sits on its own card, as every other widget's rows do.
|
||||
val card = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
|
||||
GlanceModifier.cornerRadius(16.dp).background(widgetCardColor())
|
||||
} else {
|
||||
GlanceModifier
|
||||
}
|
||||
Box(modifier = GlanceModifier.fillMaxSize().padding(6.dp)) {
|
||||
Row(
|
||||
modifier = card.fillMaxSize().clickable(open).padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(4.dp)
|
||||
.fillMaxHeight()
|
||||
.cornerRadius(2.dp)
|
||||
.background(
|
||||
themedColor(
|
||||
day = eventAccent(event.color, dark = false, data.soften),
|
||||
night = eventAccent(event.color, dark = true, data.soften),
|
||||
),
|
||||
),
|
||||
) {}
|
||||
Spacer(GlanceModifier.width(10.dp))
|
||||
Column(modifier = GlanceModifier.defaultWeight(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp, fontWeight = FontWeight.Medium),
|
||||
)
|
||||
Text(
|
||||
text = countdownText(context.resources, label),
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.primary, fontSize = 22.sp, fontWeight = FontWeight.Bold),
|
||||
)
|
||||
Text(
|
||||
text = whenText(context, event, data),
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun countdownText(res: Resources, label: CountdownLabel): String = when (label.unit) {
|
||||
CountdownUnit.NOW -> res.getString(if (label.running) R.string.widget_countdown_ending_now else R.string.widget_countdown_now)
|
||||
CountdownUnit.MINUTES -> res.getQuantityString(
|
||||
if (label.running) R.plurals.widget_countdown_ends_minutes else R.plurals.widget_countdown_in_minutes,
|
||||
label.amount,
|
||||
label.amount,
|
||||
)
|
||||
CountdownUnit.HOURS -> res.getQuantityString(
|
||||
if (label.running) R.plurals.widget_countdown_ends_hours else R.plurals.widget_countdown_in_hours,
|
||||
label.amount,
|
||||
label.amount,
|
||||
)
|
||||
CountdownUnit.DAYS -> when {
|
||||
label.amount == 1 && label.running -> res.getString(R.string.widget_countdown_ends_tomorrow)
|
||||
label.amount == 1 -> res.getString(R.string.widget_countdown_tomorrow)
|
||||
else -> res.getQuantityString(
|
||||
if (label.running) R.plurals.widget_countdown_ends_days else R.plurals.widget_countdown_in_days,
|
||||
label.amount,
|
||||
label.amount,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** When it starts, or ends once running: "Today · 14:00", "Fri · All day". */
|
||||
private fun whenText(context: Context, event: EventInstance, data: CountdownData.Ready): String {
|
||||
val counted = event.counted(systemZone())
|
||||
val running = counted.start <= data.now
|
||||
val at = if (running) counted.end else counted.start
|
||||
val date = at.toLocalDateTime(systemZone()).date
|
||||
val day = relativeDayLabel(context, date, data.today)
|
||||
return when {
|
||||
event.isAllDay && running -> context.getString(
|
||||
R.string.widget_countdown_until,
|
||||
relativeDayLabel(context, date.minus(1, DateTimeUnit.DAY), data.today),
|
||||
)
|
||||
event.isAllDay -> "$day · ${context.getString(R.string.event_detail_all_day)}"
|
||||
running -> context.getString(R.string.agenda_span_ends, "$day · ${formatWidgetTime(at, data.is24Hour)}")
|
||||
else -> "$day · ${formatWidgetTime(at, data.is24Hour)}"
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.widget.date
|
||||
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.LocalSize
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.semantics.contentDescription
|
||||
import androidx.glance.semantics.semantics
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextAlign
|
||||
import androidx.glance.text.TextStyle
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.loadToday
|
||||
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.widgetLocale
|
||||
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetData
|
||||
import de.jeanlucmakiola.floret.glance.WidgetShape
|
||||
import de.jeanlucmakiola.floret.glance.WidgetShapedBox
|
||||
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
|
||||
import de.jeanlucmakiola.floret.glance.refreshStamp
|
||||
import de.jeanlucmakiola.floret.glance.wallpaperBacking
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.toJavaDayOfWeek
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
|
||||
/** 1×1 date (#376): today's weekday above its number on an expressive shape; opens today. */
|
||||
class DateWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
|
||||
override val sizeMode = SizeMode.Exact
|
||||
|
||||
override val previewSizeMode = previewSizeOf(WidgetKind.DATE)
|
||||
|
||||
override suspend fun providePreview(context: Context, widgetCategory: Int) =
|
||||
provideSamplePreview(context, WidgetKind.DATE)
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val widgetData = WidgetData(context.loadToday(), refreshStamp(context, id))
|
||||
provideContent {
|
||||
val loaded = widgetData.current(currentRefreshStamp()) { context.loadToday() }
|
||||
DateWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DateWidgetReceiver : CalendulaWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = DateWidget()
|
||||
}
|
||||
|
||||
private val WeekdayLine = 18.dp
|
||||
|
||||
@Composable
|
||||
internal fun DateWidgetContent(today: LocalDate, appearance: WidgetAppearance, appTheme: ThemeMode) {
|
||||
CalendulaWidgetRoot(appearance, appTheme) {
|
||||
val context = LocalContext.current
|
||||
val size = LocalSize.current
|
||||
val fontScale = context.resources.configuration.fontScale
|
||||
val side = (minOf(size.width, size.height - WeekdayLine * fontScale) - 8.dp).coerceIn(32.dp, 120.dp)
|
||||
val label = localizedDateFormatter(widgetLocale(), "EEEEdMMMM").format(today.toJavaLocalDate())
|
||||
val weekday = TextStyle(
|
||||
color = GlanceTheme.colors.onSurface,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.clickable(actionStartActivity(MainActivity.openDateIntent(context, today, CalendarView.Day)))
|
||||
.semantics { contentDescription = label },
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val name = today.dayOfWeek.toJavaDayOfWeek().getDisplayName(JavaTextStyle.SHORT, widgetLocale())
|
||||
val backing = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
|
||||
GlanceModifier.wallpaperBacking(radius = 8.dp).padding(horizontal = 6.dp)
|
||||
} else {
|
||||
GlanceModifier
|
||||
}
|
||||
Text(text = name.uppercase(widgetLocale()), maxLines = 1, style = weekday, modifier = backing)
|
||||
Spacer(GlanceModifier.height(2.dp))
|
||||
WidgetShapedBox(shape = WidgetShape.COOKIE, color = GlanceTheme.colors.primary, size = side) {
|
||||
Text(
|
||||
text = today.day.toString(),
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.onPrimary,
|
||||
// Sized to the shape, so held against the font scale.
|
||||
fontSize = (side.value * 0.45f / fontScale).sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.widget.month
|
||||
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.month.DaySeating
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
||||
import de.jeanlucmakiola.calendula.ui.month.seating
|
||||
import de.jeanlucmakiola.calendula.ui.month.spanAt
|
||||
|
||||
/** A cell [cols] columns wide holding [item], or a merged gap where [item] is null. */
|
||||
internal data class GridCell<T : Any>(val item: T?, val cols: Int)
|
||||
|
||||
/**
|
||||
* The seven columns as cells and the gaps between them, consecutive empty
|
||||
* columns merged into one: the day-open taps come from the strip underneath,
|
||||
* so a gap needs no per-column view. [cellAt] gives what starts at a column
|
||||
* and how many columns it covers.
|
||||
*/
|
||||
internal fun <T : Any> mergeGaps(columns: Int, cellAt: (Int) -> GridCell<T>?): List<GridCell<T>> {
|
||||
val cells = mutableListOf<GridCell<T>>()
|
||||
var gap = 0
|
||||
var col = 0
|
||||
while (col < columns) {
|
||||
val cell = cellAt(col)
|
||||
if (cell == null) {
|
||||
gap += 1
|
||||
col += 1
|
||||
continue
|
||||
}
|
||||
if (gap > 0) cells += GridCell(null, gap)
|
||||
gap = 0
|
||||
cells += cell
|
||||
col += cell.cols
|
||||
}
|
||||
if (gap > 0) cells += GridCell(null, gap)
|
||||
return cells
|
||||
}
|
||||
|
||||
/**
|
||||
* One week of the grid seated with [laneCap] lanes. [showsOverflow] is off when
|
||||
* the week has no room for even the "+N" row.
|
||||
*/
|
||||
internal class WeekLayout(val week: MonthWeek, val laneCap: Int, val showsOverflow: Boolean = true) {
|
||||
private val seating: List<DaySeating> = week.days.indices.map { week.seating(it, laneCap) }
|
||||
|
||||
/** Lanes filled from the top; empty lanes below are not drawn. */
|
||||
val usedLanes: Int = seating.maxOfOrNull { day -> day.seated.maxOfOrNull { it.lane + 1 } ?: 0 } ?: 0
|
||||
|
||||
/** Each drawn lane as bars and merged gaps. */
|
||||
val lanes: List<List<GridCell<EventInstance>>> = List(usedLanes) { lane ->
|
||||
mergeGaps(week.days.size) { col ->
|
||||
val seat = seating[col].seated.firstOrNull { it.lane == lane } ?: return@mergeGaps null
|
||||
val endCol = if (seat.bar) week.spanAt(col, lane)?.endCol ?: col else col
|
||||
GridCell(seat.event, endCol - col + 1)
|
||||
}
|
||||
}
|
||||
|
||||
/** The "+N" row as counts and merged gaps; empty when everything fits. */
|
||||
val overflow: List<GridCell<Int>> = seating.map { it.hidden.size }.let { hidden ->
|
||||
if (hidden.none { it > 0 }) emptyList()
|
||||
else mergeGaps(hidden.size) { col -> hidden[col].takeIf { it > 0 }?.let { GridCell(it, 1) } }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Event lanes a week row [weekHeight] tall holds under its day number, held to
|
||||
* [MAX_LANES] so a tall widget's payload stays bounded (#214).
|
||||
*/
|
||||
internal fun lanesFitting(weekHeight: Dp): Int =
|
||||
((weekHeight.value - DAY_ROW_DP - LANES_TOP_DP) / LANE_ROW_DP).toInt().coerceIn(0, MAX_LANES)
|
||||
|
||||
/**
|
||||
* Seats each week in [lanes] lanes. A week whose events need more gives its
|
||||
* last lane to the "+N" row instead.
|
||||
*/
|
||||
internal fun fitWeeks(weeks: List<MonthWeek>, lanes: Int): List<WeekLayout> = weeks.map { week ->
|
||||
if (lanes == 0) return@map WeekLayout(week, laneCap = 0, showsOverflow = false)
|
||||
val needed = WeekLayout(week, UNCAPPED_LANES).usedLanes
|
||||
WeekLayout(week, if (needed <= lanes) lanes else lanes - 1)
|
||||
}
|
||||
|
||||
/** Lanes a week shows at most, however tall the widget. */
|
||||
internal const val MAX_LANES = 8
|
||||
|
||||
private const val DAY_ROW_DP = 18f
|
||||
private const val LANES_TOP_DP = 2f
|
||||
private const val LANE_ROW_DP = 15f
|
||||
private const val UNCAPPED_LANES = 64
|
||||
@@ -1,169 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.widget.month
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.graphics.Typeface
|
||||
import android.text.SpannableString
|
||||
import android.text.Spanned
|
||||
import android.text.style.StrikethroughSpan
|
||||
import android.text.style.StyleSpan
|
||||
import android.view.View
|
||||
import android.widget.RemoteViews
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.floret.glance.RemoteColor
|
||||
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
|
||||
import de.jeanlucmakiola.floret.glance.remoteColor
|
||||
import de.jeanlucmakiola.floret.glance.setImageTint
|
||||
import de.jeanlucmakiola.floret.glance.setTextColor
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.Month
|
||||
|
||||
/** The theme colours the plain-RemoteViews grid draws with. */
|
||||
internal data class GridColors(
|
||||
val onSurface: RemoteColor,
|
||||
val onSurfaceVariant: RemoteColor,
|
||||
val primary: RemoteColor,
|
||||
val onPrimary: RemoteColor,
|
||||
val cell: RemoteColor,
|
||||
)
|
||||
|
||||
/**
|
||||
* The month's week rows as one plain RemoteViews tree, embedded in the Glance
|
||||
* widget so its views don't draw on Glance's view-id pool (#372): every event
|
||||
* gets its bar, and only what can't fit the widget's height folds into "+N".
|
||||
*
|
||||
* A host may reapply a new rendering onto the views of the last one rather than
|
||||
* inflate it afresh, so every container is cleared before it's filled and every
|
||||
* view state is set both ways, never just when it differs from the layout.
|
||||
*/
|
||||
internal fun monthGridViews(
|
||||
context: Context,
|
||||
layouts: List<WeekLayout>,
|
||||
currentMonth: Month,
|
||||
today: LocalDate,
|
||||
soften: Boolean,
|
||||
colors: GridColors,
|
||||
mode: WidgetThemeMode,
|
||||
): RemoteViews {
|
||||
val grid = RemoteViews(context.packageName, R.layout.widget_month_grid)
|
||||
grid.removeAllViews(R.id.month_grid)
|
||||
layouts.forEach { grid.addView(R.id.month_grid, weekViews(context, it, currentMonth, today, soften, colors, mode)) }
|
||||
return grid
|
||||
}
|
||||
|
||||
private fun weekViews(
|
||||
context: Context,
|
||||
layout: WeekLayout,
|
||||
currentMonth: Month,
|
||||
today: LocalDate,
|
||||
soften: Boolean,
|
||||
colors: GridColors,
|
||||
mode: WidgetThemeMode,
|
||||
): RemoteViews {
|
||||
val week = RemoteViews(context.packageName, R.layout.widget_month_week)
|
||||
week.removeAllViews(R.id.week_lanes)
|
||||
layout.week.days.forEachIndexed { col, date ->
|
||||
week.setOnClickPendingIntent(STRIP_IDS[col], openDayIntent(context, date))
|
||||
// Each day its own rounded cell, as in the app's month grid. Days around
|
||||
// the month get a shade over their (opaque) cell, so they recede without
|
||||
// letting a wallpaper show through.
|
||||
week.setImageTint(context, CELL_IDS[col], colors.cell)
|
||||
val outside = date.month != currentMonth
|
||||
week.setViewVisibility(CELL_DIM_IDS[col], if (outside) View.VISIBLE else View.GONE)
|
||||
if (outside) {
|
||||
week.setImageTint(context, CELL_DIM_IDS[col], mode.remoteColor(OUTSIDE_SHADE_DAY, OUTSIDE_SHADE_NIGHT))
|
||||
}
|
||||
val dayId = DAY_IDS[col]
|
||||
val isToday = date == today
|
||||
week.setTextViewText(dayId, if (isToday) bold(date.day.toString()) else date.day.toString())
|
||||
week.setTextColor(context, dayId, when {
|
||||
isToday -> colors.onPrimary
|
||||
date.month == currentMonth -> colors.onSurface
|
||||
else -> colors.onSurfaceVariant
|
||||
})
|
||||
week.setViewVisibility(TODAY_IDS[col], if (isToday) View.VISIBLE else View.GONE)
|
||||
if (isToday) week.setImageTint(context, TODAY_IDS[col], colors.primary)
|
||||
}
|
||||
layout.lanes.forEach { cells ->
|
||||
val lane = laneViews(context)
|
||||
cells.forEach { cell ->
|
||||
val event = cell.item
|
||||
lane.addView(R.id.lane, if (event == null) gap(context, cell.cols) else bar(context, event, cell.cols, soften, mode))
|
||||
}
|
||||
week.addView(R.id.week_lanes, lane)
|
||||
}
|
||||
if (layout.showsOverflow && layout.overflow.isNotEmpty()) {
|
||||
val lane = laneViews(context)
|
||||
layout.overflow.forEach { cell ->
|
||||
val count = cell.item
|
||||
if (count == null) {
|
||||
lane.addView(R.id.lane, gap(context, cell.cols))
|
||||
} else {
|
||||
val more = RemoteViews(context.packageName, R.layout.widget_month_more)
|
||||
more.setTextViewText(R.id.more, "+$count")
|
||||
more.setTextColor(context, R.id.more, colors.onSurfaceVariant)
|
||||
lane.addView(R.id.lane, more)
|
||||
}
|
||||
}
|
||||
week.addView(R.id.week_lanes, lane)
|
||||
}
|
||||
return week
|
||||
}
|
||||
|
||||
private fun laneViews(context: Context) =
|
||||
RemoteViews(context.packageName, R.layout.widget_month_lane).apply { removeAllViews(R.id.lane) }
|
||||
|
||||
private fun gap(context: Context, cols: Int) = RemoteViews(context.packageName, GAP_LAYOUTS[cols - 1])
|
||||
|
||||
private fun bar(context: Context, event: EventInstance, cols: Int, soften: Boolean, mode: WidgetThemeMode): RemoteViews {
|
||||
val views = RemoteViews(context.packageName, BAR_LAYOUTS[cols - 1])
|
||||
val dayFill = eventFill(event.color, dark = false, soften)
|
||||
val nightFill = eventFill(event.color, dark = true, soften)
|
||||
views.setImageTint(context, R.id.bar_bg, mode.remoteColor(dayFill, nightFill))
|
||||
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
// A span, like Glance's own decoration: paint flags would drop the text's defaults.
|
||||
views.setTextViewText(R.id.bar_title, if (event.isDeclined) span(title, StrikethroughSpan()) else title)
|
||||
views.setTextColor(context, R.id.bar_title, mode.remoteColor(eventInk(dayFill), eventInk(nightFill)))
|
||||
return views
|
||||
}
|
||||
|
||||
private fun bold(text: String): CharSequence = span(text, StyleSpan(Typeface.BOLD))
|
||||
|
||||
private fun span(text: String, span: Any): CharSequence =
|
||||
SpannableString(text).apply { setSpan(span, 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) }
|
||||
|
||||
private fun openDayIntent(context: Context, date: LocalDate): PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
date.toEpochDays().toInt(),
|
||||
MainActivity.openDateIntent(context, date, CalendarView.Month),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
private val STRIP_IDS = intArrayOf(R.id.strip_0, R.id.strip_1, R.id.strip_2, R.id.strip_3, R.id.strip_4, R.id.strip_5, R.id.strip_6)
|
||||
/** The shade over a day outside the month: a dark cell needs more of it to read as dimmed. */
|
||||
private val OUTSIDE_SHADE_DAY = Color.Black.copy(alpha = 0.18f)
|
||||
private val OUTSIDE_SHADE_NIGHT = Color.Black.copy(alpha = 0.50f)
|
||||
|
||||
private val CELL_DIM_IDS = intArrayOf(
|
||||
R.id.cell_dim_0, R.id.cell_dim_1, R.id.cell_dim_2, R.id.cell_dim_3, R.id.cell_dim_4, R.id.cell_dim_5, R.id.cell_dim_6,
|
||||
)
|
||||
private val CELL_IDS = intArrayOf(R.id.cell_0, R.id.cell_1, R.id.cell_2, R.id.cell_3, R.id.cell_4, R.id.cell_5, R.id.cell_6)
|
||||
private val DAY_IDS = intArrayOf(R.id.day_0, R.id.day_1, R.id.day_2, R.id.day_3, R.id.day_4, R.id.day_5, R.id.day_6)
|
||||
private val TODAY_IDS = intArrayOf(
|
||||
R.id.today_bg_0, R.id.today_bg_1, R.id.today_bg_2, R.id.today_bg_3, R.id.today_bg_4, R.id.today_bg_5, R.id.today_bg_6,
|
||||
)
|
||||
private val BAR_LAYOUTS = intArrayOf(
|
||||
R.layout.widget_month_bar_1, R.layout.widget_month_bar_2, R.layout.widget_month_bar_3, R.layout.widget_month_bar_4,
|
||||
R.layout.widget_month_bar_5, R.layout.widget_month_bar_6, R.layout.widget_month_bar_7,
|
||||
)
|
||||
private val GAP_LAYOUTS = intArrayOf(
|
||||
R.layout.widget_month_gap_1, R.layout.widget_month_gap_2, R.layout.widget_month_gap_3, R.layout.widget_month_gap_4,
|
||||
R.layout.widget_month_gap_5, R.layout.widget_month_gap_6, R.layout.widget_month_gap_7,
|
||||
)
|
||||
@@ -1,83 +1,76 @@
|
||||
package de.jeanlucmakiola.calendula.widget.month
|
||||
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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 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.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.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.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.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
||||
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.loadMonthWidgetSource
|
||||
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
|
||||
@@ -90,16 +83,12 @@ 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 =
|
||||
@@ -110,9 +99,9 @@ private fun yearMonthOf(index: Int): YearMonth =
|
||||
* event bars and titled single-day pills (the in-app lane layout via
|
||||
* [layoutMonthWeeks]), and prev/next/today navigation.
|
||||
*
|
||||
* 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
|
||||
* 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
|
||||
* state and is read reactively in the composition ([currentState]) so the arrows
|
||||
* move it via plain recomposition, not a (here-unreliable) widget session reload.
|
||||
*
|
||||
@@ -123,7 +112,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, and lanes stop at what the height shows.
|
||||
* row here emits only what it fills.
|
||||
*/
|
||||
class MonthWidget : GlanceAppWidget() {
|
||||
|
||||
@@ -133,60 +122,27 @@ 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, so the grid draws only the
|
||||
* lanes the size can show, folding the rest into "+N".
|
||||
* Exact costs one serialised copy per host size, which is affordable now that
|
||||
* a rendering is ~190 views rather than ~740.
|
||||
*/
|
||||
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<Preferences>(context, id)[MONTH_INDEX_KEY]
|
||||
?: 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)
|
||||
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()
|
||||
provideContent {
|
||||
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)
|
||||
CalendulaGlanceTheme {
|
||||
MonthWidgetBody(source = source, dark = dark, soften = soften)
|
||||
}
|
||||
val source = loaded.content
|
||||
// A jump out of the loaded window keeps the last month it covers
|
||||
// until the reload lands, rather than drawing an empty grid.
|
||||
var lastShown by remember { mutableIntStateOf(index) }
|
||||
val shown = when {
|
||||
source !is MonthWidgetSource.Ready || index in source.months -> index
|
||||
lastShown in source.months -> lastShown
|
||||
else -> index.coerceIn(source.months)
|
||||
}
|
||||
SideEffect { lastShown = shown }
|
||||
MonthWidgetContent(
|
||||
source = source,
|
||||
index = shown,
|
||||
appearance = currentState<Preferences>().widgetAppearance,
|
||||
appTheme = loaded.appTheme,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The whole month widget showing month [index], shared with its config screen's preview (#292). */
|
||||
@Composable
|
||||
internal fun MonthWidgetContent(source: MonthWidgetSource, index: Int, appearance: WidgetAppearance, appTheme: ThemeMode) {
|
||||
CalendulaWidgetRoot(appearance, appTheme) {
|
||||
MonthWidgetBody(source = source, index = index)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step the displayed month by the `delta` action parameter (±1).
|
||||
*
|
||||
@@ -223,53 +179,57 @@ class ShiftMonthAction : ActionCallback {
|
||||
}
|
||||
}
|
||||
|
||||
/** Jump the displayed month back to the current month, reloading it. See [ShiftMonthAction]. */
|
||||
/** Jump the displayed month back to the current month. 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().updateWithRefresh(context.applicationContext, glanceId)
|
||||
MonthWidget().update(context.applicationContext, glanceId)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) {
|
||||
private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Boolean) {
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.background(GlanceTheme.colors.surface)
|
||||
.padding(horizontal = GRID_HPADDING, vertical = 6.dp),
|
||||
) {
|
||||
when (source) {
|
||||
MonthWidgetSource.NeedsPermission -> {
|
||||
MonthHeader(label = "Calendula", today = today(systemZone()))
|
||||
MonthHeader(label = "Calendula")
|
||||
PermissionMessage()
|
||||
}
|
||||
is MonthWidgetSource.Ready -> {
|
||||
val zone = systemZone()
|
||||
val index = currentState(MONTH_INDEX_KEY) ?: currentMonthIndex(zone)
|
||||
val ym = yearMonthOf(index)
|
||||
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
|
||||
// Paging past the loaded months reloads around the new one in a
|
||||
// live session, where provideGlance doesn't run again.
|
||||
val context = LocalContext.current
|
||||
val fontScale = context.resources.configuration.fontScale
|
||||
val weekHeight = (LocalSize.current.height - gridChromeHeight(fontScale)) / weeks.size
|
||||
val layouts = fitWeeks(weeks, lanesFitting(weekHeight))
|
||||
val mode = LocalWidgetThemeMode.current
|
||||
val colors = GridColors(
|
||||
onSurface = remoteColor(WidgetColorRole.ON_SURFACE),
|
||||
onSurfaceVariant = remoteColor(WidgetColorRole.ON_SURFACE_VARIANT),
|
||||
primary = remoteColor(WidgetColorRole.PRIMARY),
|
||||
onPrimary = remoteColor(WidgetColorRole.ON_PRIMARY),
|
||||
cell = remoteColor(monthCellRole()),
|
||||
)
|
||||
var loaded by remember(source) { mutableStateOf<MonthWidgetSource.Ready>(source) }
|
||||
LaunchedEffect(index) {
|
||||
if (index !in loaded.months) {
|
||||
(context.loadMonthWidgetSource(index) as? MonthWidgetSource.Ready)
|
||||
?.let { loaded = it }
|
||||
}
|
||||
}
|
||||
// Column width from the live widget size, minus our H padding.
|
||||
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
|
||||
val weeks = layoutMonthWeeks(ym, loaded.weekStart, loaded.instances, zone)
|
||||
|
||||
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(),
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -278,79 +238,89 @@ private fun MonthWidgetBody(source: MonthWidgetSource, index: Int) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonthHeader(label: String, today: LocalDate) {
|
||||
private fun MonthHeader(label: String) {
|
||||
val context = LocalContext.current
|
||||
// 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,
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
WidgetHeaderAction(
|
||||
icon = ImageProvider(R.drawable.ic_widget_chevron_left),
|
||||
HeaderIcon(
|
||||
resId = R.drawable.ic_widget_chevron_left,
|
||||
contentDescription = context.getString(R.string.widget_prev_month),
|
||||
action = actionRunCallback<ShiftMonthAction>(actionParametersOf(ShiftMonthAction.deltaKey to -1)),
|
||||
style = WidgetActionStyle.TONAL,
|
||||
onClick = GlanceModifier.clickable(
|
||||
actionRunCallback<ShiftMonthAction>(
|
||||
actionParametersOf(ShiftMonthAction.deltaKey to -1),
|
||||
),
|
||||
),
|
||||
)
|
||||
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(),
|
||||
// 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,
|
||||
contentDescription = context.getString(R.string.widget_today),
|
||||
action = actionRunCallback<ResetMonthAction>(),
|
||||
style = WidgetActionStyle.TONAL,
|
||||
onClick = GlanceModifier.clickable(actionRunCallback<ResetMonthAction>()),
|
||||
)
|
||||
WidgetHeaderActionGap()
|
||||
WidgetHeaderAction(
|
||||
icon = ImageProvider(R.drawable.ic_widget_chevron_right),
|
||||
HeaderIcon(
|
||||
resId = R.drawable.ic_widget_chevron_right,
|
||||
contentDescription = context.getString(R.string.widget_next_month),
|
||||
action = actionRunCallback<ShiftMonthAction>(actionParametersOf(ShiftMonthAction.deltaKey to 1)),
|
||||
style = WidgetActionStyle.TONAL,
|
||||
)
|
||||
WidgetHeaderActionGap()
|
||||
WidgetHeaderAction(
|
||||
icon = ImageProvider(R.drawable.ic_widget_add),
|
||||
contentDescription = context.getString(R.string.widget_new_event),
|
||||
action = actionStartActivity(MainActivity.openCreateIntent(context, today)),
|
||||
onClick = GlanceModifier.clickable(
|
||||
actionRunCallback<ShiftMonthAction>(
|
||||
actionParametersOf(ShiftMonthAction.deltaKey to 1),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekdayHeader(weekStart: DayOfWeek) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 1.dp)) {
|
||||
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()) {
|
||||
weekdayNarrowNames(weekStart).forEach { name ->
|
||||
Box(modifier = GlanceModifier.defaultWeight().padding(horizontal = 1.dp)) {
|
||||
Text(
|
||||
text = name,
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.onPrimaryContainer,
|
||||
fontSize = 11.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.cornerRadius(8.dp)
|
||||
.background(GlanceTheme.colors.primaryContainer)
|
||||
.padding(vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = name,
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = 11.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
modifier = GlanceModifier.width(colW),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The fill of a day cell: one tone off a filled root, the widget surface over a wallpaper. */
|
||||
private fun monthCellRole(background: WidgetBackground): WidgetColorRole = when (background) {
|
||||
WidgetBackground.SOLID -> WidgetColorRole.SURFACE_VARIANT
|
||||
WidgetBackground.TINTED, WidgetBackground.TRANSPARENT -> WidgetColorRole.WIDGET_BACKGROUND
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun monthCellRole(): WidgetColorRole = monthCellRole(LocalWidgetBackground.current)
|
||||
|
||||
/** Narrow weekday initials starting at [weekStart], in the device locale.
|
||||
* Computed outside the composable so the locale read stays observable-safe. */
|
||||
private fun weekdayNarrowNames(weekStart: DayOfWeek): List<String> {
|
||||
@@ -361,6 +331,266 @@ private fun weekdayNarrowNames(weekStart: DayOfWeek): List<String> {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekRow(
|
||||
week: MonthWeek,
|
||||
currentMonth: Month,
|
||||
today: LocalDate,
|
||||
dark: Boolean,
|
||||
soften: Boolean,
|
||||
colW: Dp,
|
||||
modifier: GlanceModifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val hidden = week.hiddenPerDay()
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
// Day-open targets, one full-height strip per column, sitting *under* the
|
||||
// content: a tap anywhere in a day still opens it, as in the app, without
|
||||
// every cell in the column carrying a click target of its own. The content
|
||||
// above is not clickable, so touches fall through to this layer; only an
|
||||
// event bar opts out to open its own detail.
|
||||
Row(modifier = GlanceModifier.fillMaxSize()) {
|
||||
week.days.forEach { date ->
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(colW)
|
||||
.fillMaxHeight()
|
||||
.clickable(openDayAction(context, date)),
|
||||
) {}
|
||||
}
|
||||
}
|
||||
Column(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
// Day numbers. Fixed height so every week's number row lines up whether
|
||||
// or not it holds today's larger pill.
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth().height(DAY_NUMBER_HEIGHT),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
week.days.forEach { date ->
|
||||
DayNumber(
|
||||
date = date,
|
||||
isToday = date == today,
|
||||
inMonth = date.month == currentMonth,
|
||||
colW = colW,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(GlanceModifier.height(2.dp))
|
||||
// One lane row per event row. A multi-day span is a single Box spanning
|
||||
// its columns (colW * n) so it's connected with no seam and rounded ends.
|
||||
// Lanes past the last one this week fills emit nothing at all.
|
||||
repeat(week.usedLanes()) { lane ->
|
||||
LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW)
|
||||
Spacer(GlanceModifier.height(1.dp))
|
||||
}
|
||||
if (hidden.any { it > 0 }) OverflowRow(hidden = hidden, colW = colW)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How many lanes this week actually fills, counting from the top. The grid draws
|
||||
* that many rows instead of a fixed [MAX_LANES]; an empty lane costs three views
|
||||
* per day and most weeks leave the last two empty.
|
||||
*/
|
||||
private fun MonthWeek.usedLanes(): Int {
|
||||
for (lane in MAX_LANES - 1 downTo 0) {
|
||||
val filled = spans.any { it.lane == lane } ||
|
||||
days.indices.any { col -> timedEventAt(this, lane, col, days[col]) != null }
|
||||
if (filled) return lane + 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/** Events per day that no lane had room for — the "+N" counts, 0 where all fit. */
|
||||
private fun MonthWeek.hiddenPerDay(): List<Int> = days.mapIndexed { col, date ->
|
||||
val shownSpans = spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
||||
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
|
||||
val timedShown = minOf(freeSlots, timedByDay[date].orEmpty().size)
|
||||
((countByDay[date] ?: 0) - shownSpans - timedShown).coerceAtLeast(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open [date]'s day view rooted in the month view (so back returns to the month
|
||||
* grid) — the same target the in-app month grid uses when a day cell is tapped.
|
||||
* Shared by every tappable part of a day column so, as in the app, a tap anywhere
|
||||
* on a day opens it; only an event bar on top opts out to open its own detail.
|
||||
*/
|
||||
private fun openDayAction(context: Context, date: LocalDate) =
|
||||
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month))
|
||||
|
||||
/**
|
||||
* A day's number. Every day but today is a bare centred [Text] carrying the column
|
||||
* width itself — a wrapping Box costs three views, and there are 42 of these.
|
||||
* Today keeps its filled circle, which does need the Box.
|
||||
*/
|
||||
@Composable
|
||||
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
||||
val style = TextStyle(
|
||||
color = when {
|
||||
isToday -> GlanceTheme.colors.onPrimary
|
||||
inMonth -> GlanceTheme.colors.onSurface
|
||||
else -> GlanceTheme.colors.onSurfaceVariant
|
||||
},
|
||||
fontSize = 11.sp,
|
||||
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
if (!isToday) {
|
||||
Text(text = date.day.toString(), style = style, modifier = GlanceModifier.width(colW))
|
||||
return
|
||||
}
|
||||
Box(modifier = GlanceModifier.width(colW), contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.size(DAY_NUMBER_HEIGHT)
|
||||
.cornerRadius(DAY_NUMBER_HEIGHT / 2)
|
||||
.background(GlanceTheme.colors.primary),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(text = date.day.toString(), style = style)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A bar occupying [cols] columns, or — with a null event — that many blank ones. */
|
||||
private data class LaneCell(val event: EventInstance?, val cols: Int)
|
||||
|
||||
/**
|
||||
* A lane split into bars and the gaps between them, with consecutive blank columns
|
||||
* merged into one gap: the week's day-open taps come from the strip underneath, so
|
||||
* a gap needs no per-column view and a mostly-empty lane collapses to a spacer.
|
||||
*/
|
||||
private fun MonthWeek.laneCells(lane: Int): List<LaneCell> {
|
||||
val cells = mutableListOf<LaneCell>()
|
||||
var gap = 0
|
||||
var col = 0
|
||||
fun closeGap() {
|
||||
if (gap > 0) cells += LaneCell(null, gap)
|
||||
gap = 0
|
||||
}
|
||||
while (col < 7) {
|
||||
val span = spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
|
||||
val timed = if (span == null) timedEventAt(this, lane, col, days[col]) else null
|
||||
when {
|
||||
span != null -> {
|
||||
closeGap()
|
||||
cells += LaneCell(span.event, span.endCol - col + 1)
|
||||
col = span.endCol + 1
|
||||
}
|
||||
timed != null -> {
|
||||
closeGap()
|
||||
cells += LaneCell(timed, 1)
|
||||
col += 1
|
||||
}
|
||||
else -> {
|
||||
gap += 1
|
||||
col += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
closeGap()
|
||||
return cells
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, colW: Dp) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
week.laneCells(lane).forEach { cell ->
|
||||
if (cell.event == null) {
|
||||
Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT))
|
||||
} else {
|
||||
SpanBar(
|
||||
event = cell.event,
|
||||
dark = dark,
|
||||
soften = soften,
|
||||
width = colW * cell.cols,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A single connected, rounded event bar [width] wide with its clipped title. */
|
||||
@Composable
|
||||
private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width: Dp) {
|
||||
val context = LocalContext.current
|
||||
val fill = eventFill(event.color, dark, soften)
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(width)
|
||||
.height(LANE_HEIGHT)
|
||||
.padding(horizontal = 1.dp)
|
||||
// Tap an event bar to open its detail, rooted in the month view.
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openEventIntent(
|
||||
context = context,
|
||||
eventId = event.eventId,
|
||||
beginMillis = event.start.toEpochMilliseconds(),
|
||||
endMillis = event.end.toEpochMilliseconds(),
|
||||
source = CalendarView.Month,
|
||||
),
|
||||
),
|
||||
),
|
||||
) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.cornerRadius(4.dp)
|
||||
.background(fill),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Text(
|
||||
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = ColorProvider(eventInk(fill)),
|
||||
fontSize = 9.sp,
|
||||
textDecoration = glanceDeclinedDecoration(event.isDeclined),
|
||||
),
|
||||
modifier = GlanceModifier.padding(horizontal = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The "+N" row, drawn only for weeks that hide something, gaps merged as in a lane. */
|
||||
@Composable
|
||||
private fun OverflowRow(hidden: List<Int>, colW: Dp) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
var gap = 0
|
||||
hidden.forEach { count ->
|
||||
if (count == 0) {
|
||||
gap += 1
|
||||
return@forEach
|
||||
}
|
||||
if (gap > 0) {
|
||||
Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT))
|
||||
gap = 0
|
||||
}
|
||||
Text(
|
||||
text = "+$count",
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
|
||||
modifier = GlanceModifier.width(colW).padding(start = 3.dp),
|
||||
)
|
||||
}
|
||||
if (gap > 0) Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT))
|
||||
}
|
||||
}
|
||||
|
||||
/** The timed single-day event that fills lane [lane] on day [col], if any. */
|
||||
private fun timedEventAt(week: MonthWeek, lane: Int, col: Int, date: LocalDate): EventInstance? {
|
||||
val occupied = week.spans
|
||||
.filter { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
||||
.map { it.lane }
|
||||
.toSet()
|
||||
val freeSlots = (0 until MAX_LANES).filter { it !in occupied }
|
||||
val timed = week.timedByDay[date].orEmpty()
|
||||
return timed.getOrNull(freeSlots.indexOf(lane))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionMessage() {
|
||||
val context = LocalContext.current
|
||||
@@ -375,7 +605,6 @@ private fun PermissionMessage() {
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
modifier = GlanceModifier.wallpaperBacking().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,46 @@
|
||||
package de.jeanlucmakiola.calendula.widget.month
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
|
||||
|
||||
/** Host-facing receiver for the month widget; rendering is [MonthWidget]'s. */
|
||||
class MonthWidgetReceiver : 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() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.widget.quickadd
|
||||
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.LocalSize
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.loadToday
|
||||
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||
import de.jeanlucmakiola.floret.glance.WidgetData
|
||||
import de.jeanlucmakiola.floret.glance.WidgetShape
|
||||
import de.jeanlucmakiola.floret.glance.WidgetShapedIconButton
|
||||
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
|
||||
import de.jeanlucmakiola.floret.glance.refreshStamp
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* 1×1 quick add (#435): one shaped "+" that opens the new-event editor on
|
||||
* today, with the same defaults as the app's own new-event button.
|
||||
*/
|
||||
class QuickAddWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
|
||||
// Exact: the shape fills whatever square the launcher gives it.
|
||||
override val sizeMode = SizeMode.Exact
|
||||
|
||||
override val previewSizeMode = previewSizeOf(WidgetKind.QUICK_ADD)
|
||||
|
||||
override suspend fun providePreview(context: Context, widgetCategory: Int) =
|
||||
provideSamplePreview(context, WidgetKind.QUICK_ADD)
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val widgetData = WidgetData(context.loadToday(), refreshStamp(context, id))
|
||||
provideContent {
|
||||
// The rollover moves the stamp, so the editor opens on the new day.
|
||||
val loaded = widgetData.current(currentRefreshStamp()) { context.loadToday() }
|
||||
QuickAddWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class QuickAddWidgetReceiver : CalendulaWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = QuickAddWidget()
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun QuickAddWidgetContent(today: LocalDate, appearance: WidgetAppearance, appTheme: ThemeMode) {
|
||||
CalendulaWidgetRoot(appearance, appTheme) {
|
||||
val context = LocalContext.current
|
||||
val size = LocalSize.current
|
||||
val action = actionStartActivity(MainActivity.openCreateIntent(context, today))
|
||||
Box(
|
||||
modifier = GlanceModifier.fillMaxSize().clickable(action),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
WidgetShapedIconButton(
|
||||
shape = WidgetShape.SUNNY,
|
||||
icon = ImageProvider(R.drawable.ic_widget_add),
|
||||
contentDescription = context.getString(R.string.widget_new_event),
|
||||
action = action,
|
||||
color = GlanceTheme.colors.primary,
|
||||
iconTint = GlanceTheme.colors.onPrimary,
|
||||
size = (minOf(size.width, size.height) - 12.dp).coerceIn(36.dp, 120.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,451 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.widget.split
|
||||
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.LocalSize
|
||||
import androidx.glance.action.ActionParameters
|
||||
import androidx.glance.action.actionParametersOf
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.appwidget.action.ActionCallback
|
||||
import androidx.glance.appwidget.action.actionRunCallback
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.lazy.LazyColumn
|
||||
import androidx.glance.appwidget.lazy.items
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||
import androidx.glance.background
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxHeight
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextAlign
|
||||
import androidx.glance.text.TextStyle
|
||||
import androidx.glance.unit.ColorProvider
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
|
||||
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
||||
import de.jeanlucmakiola.calendula.widget.Themed
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetEventRow
|
||||
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
|
||||
import de.jeanlucmakiola.calendula.widget.compactRowMetrics
|
||||
import de.jeanlucmakiola.calendula.widget.loadCachedMonthSource
|
||||
import de.jeanlucmakiola.calendula.widget.monthIndexOf
|
||||
import de.jeanlucmakiola.calendula.widget.monthWindowCenter
|
||||
import de.jeanlucmakiola.calendula.widget.relativeDayLabel
|
||||
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||
import de.jeanlucmakiola.calendula.widget.today
|
||||
import de.jeanlucmakiola.calendula.widget.weekOf
|
||||
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.widgetLocale
|
||||
import de.jeanlucmakiola.calendula.widget.widgetCardColor
|
||||
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
|
||||
import de.jeanlucmakiola.calendula.widget.widgetSettings
|
||||
import de.jeanlucmakiola.calendula.widget.widgetState
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetActionStyle
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetData
|
||||
import de.jeanlucmakiola.floret.glance.WidgetHeader
|
||||
import de.jeanlucmakiola.floret.glance.WidgetHeaderAction
|
||||
import de.jeanlucmakiola.floret.glance.WidgetHeaderActionGap
|
||||
import de.jeanlucmakiola.floret.glance.WidgetHeaderTextAction
|
||||
import de.jeanlucmakiola.floret.glance.WidgetMessage
|
||||
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
|
||||
import de.jeanlucmakiola.floret.glance.refreshStamp
|
||||
import de.jeanlucmakiola.floret.glance.wallpaperBacking
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toJavaDayOfWeek
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
|
||||
/** Per-widget state: the shown month (`year * 12 + monthOrdinal`) and the selected day; both follow today when absent. */
|
||||
private val MONTH_INDEX_KEY = intPreferencesKey("split_month_index")
|
||||
private val SELECTED_KEY = stringPreferencesKey("split_selected")
|
||||
|
||||
/** What the month + day widget draws: the month widget's window, plus what its list needs. */
|
||||
sealed interface MonthDayData {
|
||||
data object NeedsPermission : MonthDayData
|
||||
data class Ready(
|
||||
val source: MonthWidgetSource.Ready,
|
||||
val options: AgendaWidgetOptions,
|
||||
val is24Hour: Boolean,
|
||||
val now: Instant,
|
||||
) : MonthDayData
|
||||
}
|
||||
|
||||
/** [loaded]'s month window with what the day list draws by. */
|
||||
internal suspend fun Context.loadMonthDay(loaded: Themed<MonthWidgetSource>, options: AgendaWidgetOptions): Themed<MonthDayData> {
|
||||
val ready = loaded.content as? MonthWidgetSource.Ready ?: return Themed(MonthDayData.NeedsPermission, loaded.appTheme)
|
||||
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
|
||||
return Themed(
|
||||
MonthDayData.Ready(
|
||||
source = ready,
|
||||
options = options,
|
||||
is24Hour = settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(this)),
|
||||
now = Clock.System.now(),
|
||||
),
|
||||
loaded.appTheme,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Month + day (#441): a compact month grid beside the selected day's events,
|
||||
* like Samsung Calendar's. A day in the grid selects it, starting on today;
|
||||
* the list and its events open the app. The plain month widget keeps
|
||||
* tap-opens-the-day (#386).
|
||||
*/
|
||||
class MonthDayWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
|
||||
override val sizeMode = SizeMode.Exact
|
||||
|
||||
override val previewSizeMode = previewSizeOf(WidgetKind.MONTH_DAY)
|
||||
|
||||
override suspend fun providePreview(context: Context, widgetCategory: Int) =
|
||||
provideSamplePreview(context, WidgetKind.MONTH_DAY)
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val stamp = refreshStamp(context, id)
|
||||
val shown = context.widgetState(id)[MONTH_INDEX_KEY] ?: monthIndexOf(today(systemZone()))
|
||||
val center = monthWindowCenter(shown)
|
||||
val load: suspend (Int, Long) -> Themed<MonthDayData> = { c, s ->
|
||||
context.loadMonthDay(context.loadCachedMonthSource(id, c, s), context.widgetLoadContext(id).options)
|
||||
}
|
||||
val widgetData = WidgetData(load(center, stamp), center, stamp)
|
||||
provideContent {
|
||||
val index = currentState(MONTH_INDEX_KEY) ?: monthIndexOf(today(systemZone()))
|
||||
val currentCenter = monthWindowCenter(index)
|
||||
val currentStamp = currentRefreshStamp()
|
||||
val loaded = widgetData.current(currentCenter, currentStamp) { load(currentCenter, currentStamp) }
|
||||
val selected = currentState(SELECTED_KEY)?.let(LocalDate::parse)
|
||||
MonthDayWidgetContent(loaded.content, index, selected, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MonthDayWidgetReceiver : CalendulaWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = MonthDayWidget()
|
||||
}
|
||||
|
||||
/** Selects the tapped day; the list redraws from what's loaded. */
|
||||
class SelectDayAction : ActionCallback {
|
||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||
val date = parameters[dateKey] ?: return
|
||||
updateAppWidgetState(context, glanceId) { prefs ->
|
||||
if (date == today(systemZone()).toString()) prefs.remove(SELECTED_KEY) else prefs[SELECTED_KEY] = date
|
||||
}
|
||||
MonthDayWidget().update(context.applicationContext, glanceId)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val dateKey = ActionParameters.Key<String>("date")
|
||||
}
|
||||
}
|
||||
|
||||
/** Pages the grid by the `delta` parameter; landing on the current month follows the date again. */
|
||||
class ShiftMonthDayAction : ActionCallback {
|
||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||
val delta = parameters[deltaKey] ?: 0
|
||||
val current = monthIndexOf(today(systemZone()))
|
||||
updateAppWidgetState(context, glanceId) { prefs ->
|
||||
val next = (prefs[MONTH_INDEX_KEY] ?: current) + delta
|
||||
if (next == current) prefs.remove(MONTH_INDEX_KEY) else prefs[MONTH_INDEX_KEY] = next
|
||||
}
|
||||
MonthDayWidget().update(context.applicationContext, glanceId)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val deltaKey = ActionParameters.Key<Int>("delta")
|
||||
}
|
||||
}
|
||||
|
||||
/** Back to this month with today selected. */
|
||||
class ResetMonthDayAction : ActionCallback {
|
||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||
updateAppWidgetState(context, glanceId) { prefs ->
|
||||
prefs.remove(MONTH_INDEX_KEY)
|
||||
prefs.remove(SELECTED_KEY)
|
||||
}
|
||||
MonthDayWidget().update(context.applicationContext, glanceId)
|
||||
}
|
||||
}
|
||||
|
||||
/** The grid's days: whole weeks from [weekStart] covering [index]'s month. */
|
||||
internal fun monthGridDays(index: Int, weekStart: DayOfWeek): List<LocalDate> {
|
||||
val first = LocalDate(index / 12, index % 12 + 1, 1)
|
||||
val start = weekOf(first, weekStart).start
|
||||
val last = first.plus(1, DateTimeUnit.MONTH).plus(-1, DateTimeUnit.DAY)
|
||||
val weeks = (start.daysUntilInclusive(last) + 6) / 7
|
||||
return List(weeks * 7) { start.plus(it, DateTimeUnit.DAY) }
|
||||
}
|
||||
|
||||
private fun LocalDate.daysUntilInclusive(other: LocalDate): Int =
|
||||
(other.toEpochDays() - toEpochDays()).toInt() + 1
|
||||
|
||||
private val GridGap = 8.dp
|
||||
|
||||
@Composable
|
||||
internal fun MonthDayWidgetContent(
|
||||
data: MonthDayData,
|
||||
index: Int,
|
||||
selected: LocalDate?,
|
||||
appearance: WidgetAppearance,
|
||||
appTheme: ThemeMode,
|
||||
preview: Boolean = false,
|
||||
) {
|
||||
CalendulaWidgetRoot(appearance, appTheme) {
|
||||
val context = LocalContext.current
|
||||
when (data) {
|
||||
MonthDayData.NeedsPermission -> WidgetMessage(context.getString(R.string.widget_needs_permission))
|
||||
is MonthDayData.Ready -> {
|
||||
val source = data.source
|
||||
val day = selected ?: source.today
|
||||
val zone = systemZone()
|
||||
val days = monthGridDays(index, source.weekStart)
|
||||
val busy = groupAgendaDays(days.first(), days.last(), source.instances, zone).map { it.date }.toSet()
|
||||
Column(modifier = GlanceModifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 6.dp)) {
|
||||
Header(index, source.today)
|
||||
Spacer(GlanceModifier.height(4.dp))
|
||||
Row(modifier = GlanceModifier.fillMaxWidth().defaultWeight()) {
|
||||
val gridWidth = (LocalSize.current.width - 16.dp - GridGap) * 0.52f
|
||||
Box(modifier = GlanceModifier.width(gridWidth).fillMaxHeight()) {
|
||||
Grid(days, index, source, day, busy, gridWidth)
|
||||
}
|
||||
Spacer(GlanceModifier.width(GridGap))
|
||||
Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight()) {
|
||||
DayList(day, data, preview)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Header(index: Int, today: LocalDate) {
|
||||
val context = LocalContext.current
|
||||
WidgetHeader(
|
||||
title = formatCalendarTitle(
|
||||
date = java.time.LocalDate.of(index / 12, index % 12 + 1, 1),
|
||||
locale = widgetLocale(),
|
||||
currentYear = today.year,
|
||||
skeleton = "LLLL",
|
||||
),
|
||||
titleAction = actionStartActivity(MainActivity.openViewIntent(context, CalendarView.Month)),
|
||||
modifier = GlanceModifier.padding(bottom = 2.dp),
|
||||
titleInset = 4.dp,
|
||||
) {
|
||||
WidgetHeaderAction(
|
||||
icon = ImageProvider(R.drawable.ic_widget_chevron_left),
|
||||
contentDescription = context.getString(R.string.widget_prev_month),
|
||||
action = actionRunCallback<ShiftMonthDayAction>(actionParametersOf(ShiftMonthDayAction.deltaKey to -1)),
|
||||
style = WidgetActionStyle.TONAL,
|
||||
)
|
||||
WidgetHeaderActionGap()
|
||||
WidgetHeaderTextAction(
|
||||
text = today.day.toString(),
|
||||
contentDescription = context.getString(R.string.widget_today),
|
||||
action = actionRunCallback<ResetMonthDayAction>(),
|
||||
style = WidgetActionStyle.TONAL,
|
||||
)
|
||||
WidgetHeaderActionGap()
|
||||
WidgetHeaderAction(
|
||||
icon = ImageProvider(R.drawable.ic_widget_chevron_right),
|
||||
contentDescription = context.getString(R.string.widget_next_month),
|
||||
action = actionRunCallback<ShiftMonthDayAction>(actionParametersOf(ShiftMonthDayAction.deltaKey to 1)),
|
||||
style = WidgetActionStyle.TONAL,
|
||||
)
|
||||
WidgetHeaderActionGap()
|
||||
WidgetHeaderAction(
|
||||
icon = ImageProvider(R.drawable.ic_widget_add),
|
||||
contentDescription = context.getString(R.string.widget_new_event),
|
||||
action = actionStartActivity(MainActivity.openCreateIntent(context, today)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The month as numbers: selected day filled, today in bold primary, days
|
||||
* with events dotted, days around the month faded. Three views a day keeps
|
||||
* a six-week month well inside Glance's view ids (#372).
|
||||
*/
|
||||
@Composable
|
||||
private fun Grid(
|
||||
days: List<LocalDate>,
|
||||
index: Int,
|
||||
source: MonthWidgetSource.Ready,
|
||||
selected: LocalDate,
|
||||
busy: Set<LocalDate>,
|
||||
width: Dp,
|
||||
) {
|
||||
val locale = widgetLocale()
|
||||
val month = index % 12 + 1
|
||||
val backing = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
|
||||
GlanceModifier.cornerRadius(14.dp).background(widgetCardColor())
|
||||
} else {
|
||||
GlanceModifier
|
||||
}
|
||||
Column(modifier = backing.fillMaxSize().padding(2.dp)) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
for (i in 0 until 7) {
|
||||
Text(
|
||||
text = java.time.DayOfWeek.of((source.weekStart.ordinal + i) % 7 + 1).getDisplayName(JavaTextStyle.NARROW, locale),
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 10.sp, textAlign = TextAlign.Center),
|
||||
modifier = GlanceModifier.width((width - 4.dp) / 7),
|
||||
)
|
||||
}
|
||||
}
|
||||
days.chunked(7).forEach { week ->
|
||||
Row(modifier = GlanceModifier.fillMaxWidth().defaultWeight(), verticalAlignment = Alignment.CenterVertically) {
|
||||
week.forEach { date ->
|
||||
DayCell(date, date.month.ordinal + 1 == month, date == source.today, date == selected, date in busy, (width - 4.dp) / 7)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DayCell(date: LocalDate, inMonth: Boolean, isToday: Boolean, isSelected: Boolean, hasEvents: Boolean, width: Dp) {
|
||||
val ink = when {
|
||||
isSelected -> GlanceTheme.colors.onPrimary
|
||||
isToday -> GlanceTheme.colors.primary
|
||||
inMonth -> GlanceTheme.colors.onSurface
|
||||
else -> GlanceTheme.colors.outline
|
||||
}
|
||||
// Always set: the host reapplies onto reused views, so a dropped background would linger.
|
||||
val fill = if (isSelected) GlanceTheme.colors.primary else ColorProvider(Color.Transparent)
|
||||
Column(
|
||||
modifier = GlanceModifier.cornerRadius(10.dp).background(fill)
|
||||
.width(width)
|
||||
.fillMaxHeight()
|
||||
.clickable(actionRunCallback<SelectDayAction>(actionParametersOf(SelectDayAction.dateKey to date.toString()))),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = date.day.toString(),
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = ink,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = if (isToday || isSelected) FontWeight.Bold else FontWeight.Normal,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = if (hasEvents) "•" else " ",
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = if (isSelected) ink else GlanceTheme.colors.primary, fontSize = 7.sp, textAlign = TextAlign.Center),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DayList(day: LocalDate, data: MonthDayData.Ready, preview: Boolean) {
|
||||
val context = LocalContext.current
|
||||
val source = data.source
|
||||
val zone = systemZone()
|
||||
val events = groupAgendaDays(day, day, source.instances, zone).firstOrNull()?.events.orEmpty()
|
||||
.let { all -> if (data.options.pastDisplay == PastEventDisplay.HIDE) all.filterNot { it.hasEnded(data.now) } else all }
|
||||
val metrics = compactRowMetrics(data.options.textSize, context.resources.configuration.fontScale)
|
||||
val openDay = actionStartActivity(MainActivity.openDateIntent(context, day, CalendarView.Month))
|
||||
Column(modifier = GlanceModifier.fillMaxSize()) {
|
||||
val date = localizedDateFormatter(widgetLocale(), "EEEdMMM").format(day.toJavaLocalDate())
|
||||
val label = when (day) {
|
||||
source.today, source.today.plus(1, DateTimeUnit.DAY) -> "${relativeDayLabel(context, day, source.today)} · $date"
|
||||
else -> date
|
||||
}
|
||||
Text(
|
||||
text = label,
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = metrics.time, fontWeight = FontWeight.Medium),
|
||||
modifier = GlanceModifier
|
||||
.padding(bottom = 4.dp)
|
||||
.wallpaperBacking(radius = 8.dp)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
.clickable(openDay),
|
||||
)
|
||||
if (events.isEmpty()) {
|
||||
Box(
|
||||
modifier = GlanceModifier.fillMaxSize().cornerRadius(14.dp).background(widgetCardColor()).clickable(openDay),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = context.getString(R.string.widget_day_empty),
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.time, textAlign = TextAlign.Center),
|
||||
modifier = GlanceModifier.padding(8.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val row: @Composable (Int) -> Unit = { i ->
|
||||
val event = events[i]
|
||||
WidgetEventRow(
|
||||
event = event,
|
||||
secondary = widgetTimeLabel(context, event, day, data.is24Hour),
|
||||
soften = source.soften,
|
||||
metrics = metrics,
|
||||
position = positionOf(i, events.size),
|
||||
source = CalendarView.Month,
|
||||
dimmed = data.options.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now),
|
||||
)
|
||||
}
|
||||
if (preview) {
|
||||
Column(modifier = GlanceModifier.fillMaxSize()) { repeat(minOf(events.size, 8)) { row(it) } }
|
||||
} else {
|
||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) { items(events.size) { row(it) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.widget.today
|
||||
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.LocalSize
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.lazy.LazyColumn
|
||||
import androidx.glance.appwidget.lazy.items
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.background
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxHeight
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.semantics.contentDescription
|
||||
import androidx.glance.semantics.semantics
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextAlign
|
||||
import androidx.glance.text.TextStyle
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
|
||||
import de.jeanlucmakiola.calendula.widget.DaysWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.EventRowMetrics
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetEventRow
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
|
||||
import de.jeanlucmakiola.calendula.widget.compactRowMetrics
|
||||
import de.jeanlucmakiola.calendula.widget.loadDays
|
||||
import de.jeanlucmakiola.calendula.widget.relativeDayLabel
|
||||
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.widgetLocale
|
||||
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
|
||||
import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetData
|
||||
import de.jeanlucmakiola.floret.glance.WidgetMessage
|
||||
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
|
||||
import de.jeanlucmakiola.floret.glance.refreshStamp
|
||||
import de.jeanlucmakiola.floret.glance.wallpaperBacking
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toJavaDayOfWeek
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
|
||||
/**
|
||||
* Today list (#438): only today's events beside today's date pill. Once
|
||||
* they're over, or with none at all, it says so and shows tomorrow's first
|
||||
* one or two events in a quieter style.
|
||||
*/
|
||||
class TodayWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
|
||||
// Exact: whether one or two of tomorrow's events fit follows the height.
|
||||
override val sizeMode = SizeMode.Exact
|
||||
|
||||
override val previewSizeMode = previewSizeOf(WidgetKind.TODAY)
|
||||
|
||||
override suspend fun providePreview(context: Context, widgetCategory: Int) =
|
||||
provideSamplePreview(context, WidgetKind.TODAY)
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val widgetData = WidgetData(context.loadTodayList(context.widgetLoadContext(id)), refreshStamp(context, id))
|
||||
provideContent {
|
||||
val loaded = widgetData.current(currentRefreshStamp()) { context.loadTodayList(context.widgetLoadContext(id)) }
|
||||
TodayWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TodayWidgetReceiver : CalendulaWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = TodayWidget()
|
||||
}
|
||||
|
||||
internal suspend fun Context.loadTodayList(load: WidgetLoadContext) = loadDays(load) { today, _ ->
|
||||
today..today.plus(1, DateTimeUnit.DAY)
|
||||
}
|
||||
|
||||
private val PillWidth = 44.dp
|
||||
private val RowGap = 2.dp
|
||||
|
||||
/** What the list side shows: today's events, or the look ahead once none are left. */
|
||||
internal sealed interface TodayRows {
|
||||
data class Today(val events: List<EventInstance>) : TodayRows
|
||||
data class NothingLeft(val tomorrow: List<EventInstance>) : TodayRows
|
||||
}
|
||||
|
||||
internal fun todayRows(data: DaysWidgetData.Ready): TodayRows {
|
||||
val today = data.eventsOn(data.today)
|
||||
if (today.none { !it.hasEnded(data.now) }) {
|
||||
return TodayRows.NothingLeft(data.eventsOn(data.today.plus(1, DateTimeUnit.DAY)))
|
||||
}
|
||||
val shown = if (data.options.pastDisplay == PastEventDisplay.HIDE) today.filterNot { it.hasEnded(data.now) } else today
|
||||
return TodayRows.Today(shown)
|
||||
}
|
||||
|
||||
/** The whole widget, shared with its config preview, which draws its list as a plain column. */
|
||||
@Composable
|
||||
internal fun TodayWidgetContent(
|
||||
data: DaysWidgetData,
|
||||
appearance: WidgetAppearance,
|
||||
appTheme: ThemeMode,
|
||||
preview: Boolean = false,
|
||||
) {
|
||||
CalendulaWidgetRoot(appearance, appTheme) {
|
||||
val context = LocalContext.current
|
||||
when (data) {
|
||||
DaysWidgetData.NeedsPermission -> WidgetMessage(context.getString(R.string.widget_needs_permission))
|
||||
is DaysWidgetData.Ready -> Row(modifier = GlanceModifier.fillMaxSize().padding(6.dp)) {
|
||||
TodayPill(data.today)
|
||||
Spacer(GlanceModifier.width(6.dp))
|
||||
val metrics = compactRowMetrics(data.options.textSize, context.resources.configuration.fontScale)
|
||||
Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight()) {
|
||||
when (val rows = todayRows(data)) {
|
||||
is TodayRows.Today -> TodayList(rows.events, data, metrics, preview)
|
||||
is TodayRows.NothingLeft -> NothingLeft(rows.tomorrow, data, metrics)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TodayList(events: List<EventInstance>, data: DaysWidgetData.Ready, metrics: EventRowMetrics, preview: Boolean) {
|
||||
val context = LocalContext.current
|
||||
val row: @Composable (Int) -> Unit = { index ->
|
||||
val event = events[index]
|
||||
WidgetEventRow(
|
||||
event = event,
|
||||
secondary = widgetTimeLabel(context, event, data.today, data.is24Hour),
|
||||
soften = data.soften,
|
||||
metrics = metrics,
|
||||
position = positionOf(index, events.size),
|
||||
source = CalendarView.Day,
|
||||
dimmed = data.options.pastDisplay == PastEventDisplay.DIM && event.hasEnded(data.now),
|
||||
)
|
||||
}
|
||||
if (preview) {
|
||||
val fits = ((LocalSize.current.height - 12.dp + RowGap) / (metrics.rowHeight + RowGap)).toInt().coerceAtLeast(1)
|
||||
Column(modifier = GlanceModifier.fillMaxSize()) { repeat(minOf(events.size, fits)) { row(it) } }
|
||||
} else {
|
||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) { items(events.size) { row(it) } }
|
||||
}
|
||||
}
|
||||
|
||||
/** "Nothing left today", then as many of tomorrow's first two events as the height holds. */
|
||||
@Composable
|
||||
private fun NothingLeft(tomorrow: List<EventInstance>, data: DaysWidgetData.Ready, metrics: EventRowMetrics) {
|
||||
val context = LocalContext.current
|
||||
val fontScale = context.resources.configuration.fontScale
|
||||
val lineHeight = 20.dp * fontScale
|
||||
val room = LocalSize.current.height - 12.dp - lineHeight
|
||||
val fits = ((room + RowGap) / (metrics.rowHeight + RowGap)).toInt().coerceIn(0, 2)
|
||||
val ahead = tomorrow.take(fits)
|
||||
val backing = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
|
||||
GlanceModifier.wallpaperBacking(radius = 8.dp).padding(horizontal = 8.dp)
|
||||
} else {
|
||||
GlanceModifier.padding(horizontal = 4.dp)
|
||||
}
|
||||
Column(modifier = GlanceModifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = context.getString(R.string.widget_nothing_left_today),
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.title, fontWeight = FontWeight.Medium),
|
||||
modifier = backing.height(lineHeight),
|
||||
)
|
||||
val tomorrowDate = data.today.plus(1, DateTimeUnit.DAY)
|
||||
val tomorrowLabel = relativeDayLabel(context, tomorrowDate, data.today)
|
||||
ahead.forEachIndexed { index, event ->
|
||||
WidgetEventRow(
|
||||
event = event,
|
||||
secondary = "$tomorrowLabel · ${widgetTimeLabel(context, event, tomorrowDate, data.is24Hour)}",
|
||||
soften = data.soften,
|
||||
metrics = metrics,
|
||||
position = positionOf(index, ahead.size),
|
||||
source = CalendarView.Day,
|
||||
quiet = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Today's weekday and number, filled like the agenda's today pill; opens today. */
|
||||
@Composable
|
||||
private fun TodayPill(today: LocalDate) {
|
||||
val context = LocalContext.current
|
||||
val locale = widgetLocale()
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.width(PillWidth)
|
||||
.fillMaxHeight()
|
||||
.cornerRadius(PillWidth / 2)
|
||||
.background(GlanceTheme.colors.primary)
|
||||
.clickable(actionStartActivity(MainActivity.openDateIntent(context, today, CalendarView.Day)))
|
||||
.semantics { contentDescription = localizedDateFormatter(locale, "EEEEdMMMM").format(today.toJavaLocalDate()) },
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = today.dayOfWeek.toJavaDayOfWeek().getDisplayName(JavaTextStyle.SHORT, locale),
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onPrimary, fontSize = 11.sp, textAlign = TextAlign.Center),
|
||||
)
|
||||
Text(
|
||||
text = today.day.toString(),
|
||||
maxLines = 1,
|
||||
style = TextStyle(
|
||||
color = GlanceTheme.colors.onPrimary,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.widget.upcoming
|
||||
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.LocalSize
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
|
||||
import de.jeanlucmakiola.calendula.widget.DaysWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetEventRow
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetLoadContext
|
||||
import de.jeanlucmakiola.calendula.widget.compactRowMetrics
|
||||
import de.jeanlucmakiola.calendula.widget.loadDays
|
||||
import de.jeanlucmakiola.calendula.widget.relativeDayLabel
|
||||
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.widgetLocale
|
||||
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
|
||||
import de.jeanlucmakiola.calendula.widget.widgetTimeLabel
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetData
|
||||
import de.jeanlucmakiola.floret.glance.WidgetMessage
|
||||
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
|
||||
import de.jeanlucmakiola.floret.glance.refreshStamp
|
||||
import de.jeanlucmakiola.floret.glance.wallpaperBacking
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toJavaDayOfWeek
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
|
||||
/**
|
||||
* 2×2 upcoming (#437): today's date on top, then the next events across the
|
||||
* widget's range in compact rows, as many as its height holds. The date opens
|
||||
* the agenda, an event opens itself.
|
||||
*/
|
||||
class UpcomingWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
|
||||
// Exact: how many rows show follows the height.
|
||||
override val sizeMode = SizeMode.Exact
|
||||
|
||||
override val previewSizeMode = previewSizeOf(WidgetKind.UPCOMING)
|
||||
|
||||
override suspend fun providePreview(context: Context, widgetCategory: Int) =
|
||||
provideSamplePreview(context, WidgetKind.UPCOMING)
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val widgetData = WidgetData(context.loadUpcoming(context.widgetLoadContext(id)), refreshStamp(context, id))
|
||||
provideContent {
|
||||
val loaded = widgetData.current(currentRefreshStamp()) { context.loadUpcoming(context.widgetLoadContext(id)) }
|
||||
UpcomingWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class UpcomingWidgetReceiver : CalendulaWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = UpcomingWidget()
|
||||
}
|
||||
|
||||
internal suspend fun Context.loadUpcoming(load: WidgetLoadContext) = loadDays(load) { today, weekStart ->
|
||||
today..today.plus(load.options.range.dayCount(today, weekStart) - 1, DateTimeUnit.DAY)
|
||||
}
|
||||
|
||||
/** Most rows a tall widget lists; past this it is an agenda, which is its own widget. */
|
||||
private const val MAX_ROWS = 8
|
||||
|
||||
private val RowGap = 2.dp
|
||||
|
||||
/** Every event still to come or running, each once, on the first day it shows. */
|
||||
internal fun upcomingEvents(data: DaysWidgetData.Ready): List<Pair<LocalDate, EventInstance>> =
|
||||
data.days
|
||||
.flatMap { day -> day.events.map { day.date to it } }
|
||||
.filterNot { (_, event) -> event.hasEnded(data.now) }
|
||||
.distinctBy { (_, event) -> event.instanceId to event.start }
|
||||
|
||||
@Composable
|
||||
internal fun UpcomingWidgetContent(data: DaysWidgetData, appearance: WidgetAppearance, appTheme: ThemeMode) {
|
||||
CalendulaWidgetRoot(appearance, appTheme) {
|
||||
val context = LocalContext.current
|
||||
when (data) {
|
||||
DaysWidgetData.NeedsPermission -> WidgetMessage(context.getString(R.string.widget_needs_permission))
|
||||
is DaysWidgetData.Ready -> {
|
||||
val fontScale = context.resources.configuration.fontScale
|
||||
val metrics = compactRowMetrics(data.options.textSize, fontScale)
|
||||
val headerHeight = 44.dp * fontScale
|
||||
val room = LocalSize.current.height - 16.dp - headerHeight
|
||||
val fits = ((room + RowGap) / (metrics.rowHeight + RowGap)).toInt().coerceIn(1, MAX_ROWS)
|
||||
val events = upcomingEvents(data).take(fits)
|
||||
Column(modifier = GlanceModifier.fillMaxSize().padding(8.dp)) {
|
||||
DateHeader(data.today, headerHeight)
|
||||
if (events.isEmpty()) {
|
||||
WidgetMessage(context.getString(R.string.widget_upcoming_empty))
|
||||
} else {
|
||||
events.forEachIndexed { index, (day, event) ->
|
||||
val time = widgetTimeLabel(context, event, day, data.is24Hour)
|
||||
WidgetEventRow(
|
||||
event = event,
|
||||
secondary = if (day == data.today) time else "${relativeDayLabel(context, day, data.today)} · $time",
|
||||
soften = data.soften,
|
||||
metrics = metrics,
|
||||
position = positionOf(index, events.size),
|
||||
source = CalendarView.Agenda,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Today's number beside its weekday and month; opens the agenda. */
|
||||
@Composable
|
||||
private fun DateHeader(today: LocalDate, height: Dp) {
|
||||
val context = LocalContext.current
|
||||
val locale = widgetLocale()
|
||||
val backing = if (LocalWidgetBackground.current == WidgetBackground.TRANSPARENT) {
|
||||
GlanceModifier.wallpaperBacking(radius = 12.dp).padding(horizontal = 8.dp)
|
||||
} else {
|
||||
GlanceModifier.padding(horizontal = 4.dp)
|
||||
}
|
||||
Box(modifier = GlanceModifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) {
|
||||
Row(
|
||||
modifier = backing.clickable(actionStartActivity(MainActivity.openViewIntent(context, CalendarView.Agenda))),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = today.day.toString(),
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.primary, fontSize = 28.sp, fontWeight = FontWeight.Bold),
|
||||
)
|
||||
Spacer(GlanceModifier.width(8.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = today.dayOfWeek.toJavaDayOfWeek().getDisplayName(JavaTextStyle.FULL, locale),
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 13.sp, fontWeight = FontWeight.Medium),
|
||||
)
|
||||
Text(
|
||||
text = java.time.Month.of(today.month.ordinal + 1).getDisplayName(JavaTextStyle.FULL_STANDALONE, locale),
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.widget.week
|
||||
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetKind
|
||||
import de.jeanlucmakiola.calendula.widget.previewSizeOf
|
||||
import de.jeanlucmakiola.calendula.widget.provideSamplePreview
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.glance.ColorFilter
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.Image
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.LocalContext
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.background
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.Spacer
|
||||
import androidx.glance.layout.fillMaxHeight
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.height
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.size
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.semantics.contentDescription
|
||||
import androidx.glance.semantics.semantics
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextAlign
|
||||
import androidx.glance.text.TextStyle
|
||||
import androidx.glance.unit.ColorProvider
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaWidgetRoot
|
||||
import de.jeanlucmakiola.calendula.widget.DaysWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.loadDays
|
||||
import de.jeanlucmakiola.calendula.widget.weekOf
|
||||
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.widgetLocale
|
||||
import de.jeanlucmakiola.calendula.widget.widgetCardColor
|
||||
import de.jeanlucmakiola.calendula.widget.widgetLoadContext
|
||||
import de.jeanlucmakiola.floret.glance.WidgetData
|
||||
import de.jeanlucmakiola.floret.glance.WidgetMessage
|
||||
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
|
||||
import de.jeanlucmakiola.floret.glance.refreshStamp
|
||||
import de.jeanlucmakiola.floret.glance.themedColor
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toJavaDayOfWeek
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
|
||||
/**
|
||||
* 4×1 week strip (#436): the current week's seven days from the app's week
|
||||
* start, today filled, a dot per event colour under each day. A day opens
|
||||
* its day view.
|
||||
*/
|
||||
class WeekStripWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
|
||||
override val sizeMode = SizeMode.Single
|
||||
|
||||
override val previewSizeMode = previewSizeOf(WidgetKind.WEEK_STRIP)
|
||||
|
||||
override suspend fun providePreview(context: Context, widgetCategory: Int) =
|
||||
provideSamplePreview(context, WidgetKind.WEEK_STRIP)
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val widgetData = WidgetData(context.loadWeek(id), refreshStamp(context, id))
|
||||
provideContent {
|
||||
val loaded = widgetData.current(currentRefreshStamp()) { context.loadWeek(id) }
|
||||
WeekStripWidgetContent(loaded.content, currentState<Preferences>().widgetAppearance, loaded.appTheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class WeekStripWidgetReceiver : CalendulaWidgetReceiver() {
|
||||
override val glanceAppWidget: GlanceAppWidget = WeekStripWidget()
|
||||
}
|
||||
|
||||
private suspend fun Context.loadWeek(id: GlanceId) = loadDays(widgetLoadContext(id), ::weekOf)
|
||||
|
||||
/** Most dots a day shows; more colours than this read as noise at this size. */
|
||||
private const val MAX_DOTS = 3
|
||||
|
||||
private val DayGap = 4.dp
|
||||
private val DotSize = 5.dp
|
||||
|
||||
@Composable
|
||||
internal fun WeekStripWidgetContent(data: DaysWidgetData, appearance: WidgetAppearance, appTheme: ThemeMode) {
|
||||
CalendulaWidgetRoot(appearance, appTheme) {
|
||||
when (data) {
|
||||
DaysWidgetData.NeedsPermission ->
|
||||
WidgetMessage(LocalContext.current.getString(R.string.widget_needs_permission))
|
||||
is DaysWidgetData.Ready -> Row(
|
||||
modifier = GlanceModifier.fillMaxSize().padding(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
for (i in 0 until 7) {
|
||||
if (i > 0) Spacer(GlanceModifier.width(DayGap))
|
||||
Box(modifier = GlanceModifier.defaultWeight().fillMaxHeight()) {
|
||||
DayTile(data.firstDay.plus(i, DateTimeUnit.DAY), data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DayTile(date: LocalDate, data: DaysWidgetData.Ready) {
|
||||
val context = LocalContext.current
|
||||
val isToday = date == data.today
|
||||
val container = if (isToday) GlanceTheme.colors.primary else widgetCardColor()
|
||||
val ink = when {
|
||||
isToday -> GlanceTheme.colors.onPrimary
|
||||
date < data.today -> GlanceTheme.colors.onSurfaceVariant
|
||||
else -> GlanceTheme.colors.onSurface
|
||||
}
|
||||
val locale = widgetLocale()
|
||||
val colors = data.eventsOn(date).map { it.color }.distinct()
|
||||
val label = localizedDateFormatter(locale, "EEEEdMMMM").format(date.toJavaLocalDate())
|
||||
Column(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
.cornerRadius(14.dp)
|
||||
.background(container)
|
||||
.clickable(actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Week)))
|
||||
.semantics {
|
||||
contentDescription = if (colors.isEmpty()) label else context.resources.getQuantityString(
|
||||
R.plurals.widget_day_with_events, data.eventsOn(date).size, label, data.eventsOn(date).size,
|
||||
)
|
||||
},
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = date.dayOfWeek.toJavaDayOfWeek().getDisplayName(JavaTextStyle.SHORT, locale),
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = ink, fontSize = 11.sp, textAlign = TextAlign.Center),
|
||||
)
|
||||
Text(
|
||||
text = date.day.toString(),
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = ink, fontSize = 17.sp, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center),
|
||||
)
|
||||
Spacer(GlanceModifier.height(3.dp))
|
||||
// A dot row is always drawn, so the numbers line up across days.
|
||||
Row(modifier = GlanceModifier.height(DotSize)) {
|
||||
colors.take(MAX_DOTS).forEachIndexed { index, color ->
|
||||
if (index > 0) Spacer(GlanceModifier.width(3.dp))
|
||||
Dot(dotColor(color, isToday, data.soften))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A mark on the tile: the accent tone on a card, the lighter fill tone on today's primary. */
|
||||
@Composable
|
||||
private fun dotColor(color: Int, onPrimary: Boolean, soften: Boolean): ColorProvider = if (onPrimary) {
|
||||
themedColor(day = eventFill(color, dark = false, soften), night = eventFill(color, dark = true, soften))
|
||||
} else {
|
||||
themedColor(day = eventAccent(color, dark = false, soften), night = eventAccent(color, dark = true, soften))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Dot(color: ColorProvider) {
|
||||
Image(
|
||||
provider = ImageProvider(R.drawable.widget_dot),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(color),
|
||||
modifier = GlanceModifier.size(DotSize),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp"
|
||||
android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="@android:color/white"
|
||||
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp"
|
||||
android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="@android:color/white"
|
||||
android:pathData="M19,3h-1V1h-2v2H8V1H6v2H5C3.89,3 3.01,3.9 3.01,5L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2zM19,19H5V8h14v11zM7,10h5v5H7z"/>
|
||||
</vector>
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#FFFFFF" />
|
||||
<solid android:color="#4F7CAC" />
|
||||
<corners android:radius="3dp" />
|
||||
</shape>
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/widget_preview_cell" />
|
||||
<corners android:radius="12dp" />
|
||||
</shape>
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#FFFFFF" />
|
||||
<corners android:radius="14dp" />
|
||||
</shape>
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/widget_preview_outside" />
|
||||
<corners android:radius="12dp" />
|
||||
</shape>
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/widget_preview_primary" />
|
||||
<corners android:radius="14dp" />
|
||||
</shape>
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#FFFFFFFF" />
|
||||
</shape>
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<corners android:radius="4dp" />
|
||||
<solid android:color="@android:color/white" />
|
||||
</shape>
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<corners android:radius="8dp" />
|
||||
<solid android:color="@android:color/white" />
|
||||
</shape>
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
|
||||
<solid android:color="@android:color/white" />
|
||||
</shape>
|
||||
@@ -1,26 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:paddingHorizontal="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/bar_bg"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_bar_bg" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bar_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_vertical"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:paddingHorizontal="3dp"
|
||||
android:textSize="9sp" />
|
||||
</FrameLayout>
|
||||
@@ -1,26 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="2"
|
||||
android:paddingHorizontal="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/bar_bg"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_bar_bg" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bar_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_vertical"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:paddingHorizontal="3dp"
|
||||
android:textSize="9sp" />
|
||||
</FrameLayout>
|
||||
@@ -1,26 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="3"
|
||||
android:paddingHorizontal="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/bar_bg"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_bar_bg" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bar_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_vertical"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:paddingHorizontal="3dp"
|
||||
android:textSize="9sp" />
|
||||
</FrameLayout>
|
||||
@@ -1,26 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="4"
|
||||
android:paddingHorizontal="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/bar_bg"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_bar_bg" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bar_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_vertical"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:paddingHorizontal="3dp"
|
||||
android:textSize="9sp" />
|
||||
</FrameLayout>
|
||||
@@ -1,26 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="5"
|
||||
android:paddingHorizontal="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/bar_bg"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_bar_bg" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bar_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_vertical"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:paddingHorizontal="3dp"
|
||||
android:textSize="9sp" />
|
||||
</FrameLayout>
|
||||
@@ -1,26 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="6"
|
||||
android:paddingHorizontal="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/bar_bg"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_bar_bg" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bar_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_vertical"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:paddingHorizontal="3dp"
|
||||
android:textSize="9sp" />
|
||||
</FrameLayout>
|
||||
@@ -1,26 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="7"
|
||||
android:paddingHorizontal="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/bar_bg"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_bar_bg" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bar_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_vertical"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:paddingHorizontal="3dp"
|
||||
android:textSize="9sp" />
|
||||
</FrameLayout>
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1" />
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="2" />
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user