Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
-16
@@ -7,10 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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]).
|
||||
@@ -18,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
|
||||
|
||||
@@ -1887,5 +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
|
||||
|
||||
@@ -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 = 2230099
|
||||
versionName = "2.23.0"
|
||||
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"
|
||||
@@ -353,13 +343,12 @@
|
||||
</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"
|
||||
@@ -376,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,8 +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.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
|
||||
@@ -44,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()
|
||||
@@ -61,7 +58,7 @@ class CalendulaApp : Application() {
|
||||
*/
|
||||
private fun reconcileWidgetRollover() {
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||
syncWidgetRollover(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
|
||||
@@ -37,8 +36,6 @@ import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
||||
import de.jeanlucmakiola.calendula.domain.sharedTextOrNull
|
||||
import de.jeanlucmakiola.calendula.ui.ExternalRequests
|
||||
import de.jeanlucmakiola.calendula.ui.InsertRequest
|
||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalMultiDayCount
|
||||
@@ -62,8 +59,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
|
||||
@@ -72,6 +67,9 @@ import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
/** A prefilled create form from an external launch, with the source it came from. */
|
||||
private data class InsertRequest(val form: EventForm, val source: ImportSource)
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
@@ -83,12 +81,23 @@ class MainActivity : AppCompatActivity() {
|
||||
// picks up the in-app override instead of the night resource qualifier.
|
||||
private var systemBarsDark = false
|
||||
|
||||
private val requests = ExternalRequests(
|
||||
onExternalFormClosed = {
|
||||
insertFormOpen = false
|
||||
if (launchedForInsert) finish()
|
||||
},
|
||||
)
|
||||
// The occurrence a reminder notification was tapped for (eventId, begin,
|
||||
// end — the detail screen's key shape). singleTop + onNewIntent route a
|
||||
// tap into the running activity; CalendarHost consumes and clears it.
|
||||
private var requestedDetailKey by mutableStateOf<LongArray?>(null)
|
||||
|
||||
// A navigation a home-screen widget asked for (open a date / start a
|
||||
// create). Consumed once by CalendarHost, same pattern as the detail key.
|
||||
private var requestedNav by mutableStateOf<WidgetNavRequest?>(null)
|
||||
|
||||
// An .ics file opened/shared into the app (ACTION_VIEW/SEND). Consumed once
|
||||
// by CalendarHost's import flow.
|
||||
private var requestedImportUri by mutableStateOf<Uri?>(null)
|
||||
|
||||
// A prefilled new-event form from an external ACTION_INSERT launch (another
|
||||
// app/widget asking us to create an event, issue #30) or shared text (#306).
|
||||
// Consumed once by CalendarHost, which opens it in the create form for review.
|
||||
private var requestedInsert by mutableStateOf<InsertRequest?>(null)
|
||||
|
||||
// Whether such a form is still open. Only then does a recreation re-read the
|
||||
// intent to bring it back; a saved or dismissed one stays closed.
|
||||
@@ -98,6 +107,11 @@ class MainActivity : AppCompatActivity() {
|
||||
// app that asked instead of leaving the calendar on top of it.
|
||||
private var launchedForInsert = false
|
||||
|
||||
// An external "edit this event" (ACTION_EDIT on content://.../events/<id>):
|
||||
// opens the occurrence in the edit form. Same occurrence-key shape as the
|
||||
// detail channel; consumed once by CalendarHost.
|
||||
private var requestedEditKey by mutableStateOf<LongArray?>(null)
|
||||
|
||||
// A captured crash report awaiting the user's decision, surfaced as a dialog
|
||||
// over the calendar on the next launch (the single-crash path). A startup
|
||||
// crash-loop is handled out of band, before setContent — see below.
|
||||
@@ -127,17 +141,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,30 +160,25 @@ 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()
|
||||
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
||||
requestedEditKey = intent.editEventKeyOrNull()
|
||||
}
|
||||
requests.nav = intent.navRequestOrNull()
|
||||
requestedNav = intent.navRequestOrNull()
|
||||
?.takeUnless { replaying && it is WidgetNavRequest.OpenEvent }
|
||||
requests.importUri = intent.importUriOrNull()
|
||||
requestedImportUri = intent.importUriOrNull()
|
||||
val insertOpenBefore = savedInstanceState?.getBoolean(KEY_INSERT_FORM_OPEN) == true
|
||||
requests.insert = intent.createRequestOrNull()?.takeIf { !replaying || insertOpenBefore }
|
||||
insertFormOpen = requests.insert != null
|
||||
requestedInsert = intent.createRequestOrNull()?.takeIf { !replaying || insertOpenBefore }
|
||||
insertFormOpen = requestedInsert != null
|
||||
launchedForInsert = savedInstanceState?.getBoolean(KEY_LAUNCHED_FOR_INSERT)
|
||||
?: (requests.insert != null)
|
||||
?: (requestedInsert != null)
|
||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||
setContent {
|
||||
// One activity-scoped SettingsViewModel drives both the theme here
|
||||
// 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 +216,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()) {
|
||||
@@ -228,7 +230,21 @@ class MainActivity : AppCompatActivity() {
|
||||
) {
|
||||
RootScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
requests = requests,
|
||||
requestedDetailKey = requestedDetailKey,
|
||||
onDetailKeyConsumed = { requestedDetailKey = null },
|
||||
widgetNavRequest = requestedNav,
|
||||
onWidgetNavConsumed = { requestedNav = null },
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = { requestedImportUri = null },
|
||||
requestedInsertForm = requestedInsert?.form,
|
||||
requestedInsertSource = requestedInsert?.source ?: ImportSource.Insert,
|
||||
onInsertConsumed = { requestedInsert = null },
|
||||
onExternalFormClosed = {
|
||||
insertFormOpen = false
|
||||
if (launchedForInsert) finish()
|
||||
},
|
||||
requestedEditKey = requestedEditKey,
|
||||
onEditKeyConsumed = { requestedEditKey = null },
|
||||
)
|
||||
}
|
||||
// A persistent corner marker so a debug build is never
|
||||
@@ -288,22 +304,14 @@ 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 }
|
||||
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it }
|
||||
intent.navRequestOrNull()?.let { requestedNav = it }
|
||||
intent.importUriOrNull()?.let { requestedImportUri = it }
|
||||
intent.createRequestOrNull()?.let {
|
||||
requests.insert = it
|
||||
requestedInsert = it
|
||||
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
|
||||
}
|
||||
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -324,10 +332,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 +388,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 +409,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 +535,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 +627,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?)
|
||||
|
||||
@@ -725,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()
|
||||
@@ -1116,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)
|
||||
}
|
||||
@@ -1691,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"
|
||||
|
||||
|
||||
@@ -18,10 +18,6 @@ interface CalendarRepository {
|
||||
|
||||
/** 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.
|
||||
|
||||
@@ -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,18 +3,11 @@ 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
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
@@ -30,12 +23,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,27 +38,8 @@ class AndroidTodaySource @Inject constructor(
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.shareIn(scope, SharingStarted.WhileSubscribed(replayExpirationMillis = 0), replay = 1)
|
||||
}
|
||||
|
||||
private const val MINUTE_MILLIS = 60_000L
|
||||
|
||||
fun currentDate(): LocalDate = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
|
||||
/**
|
||||
* Today as a hot flow seeded with [initial]. [onRollover] runs with the previous
|
||||
* and the new date each time the day changes while collected, so a screen left
|
||||
* open over midnight can move off yesterday (#373).
|
||||
*/
|
||||
fun Flow<LocalDate>.stateFollowingDays(
|
||||
scope: CoroutineScope,
|
||||
initial: LocalDate,
|
||||
onRollover: (old: LocalDate, new: LocalDate) -> Unit,
|
||||
): StateFlow<LocalDate> {
|
||||
var last = initial
|
||||
return onEach { date ->
|
||||
val old = last
|
||||
last = date
|
||||
if (old != date) onRollover(old, date)
|
||||
}.stateWhileSubscribed(scope, initial)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,10 +4,8 @@ import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
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
|
||||
@@ -30,12 +28,10 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
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
|
||||
@@ -43,7 +39,6 @@ import androidx.compose.ui.layout.positionInWindow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.data.time.currentDate
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
||||
@@ -68,7 +63,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
|
||||
@@ -104,6 +99,7 @@ import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
@@ -120,18 +116,29 @@ import kotlin.time.Instant
|
||||
* view remains. So back from a widget-opened screen returns to that widget's
|
||||
* view, then home; and back through pill switches walks the views in reverse.
|
||||
*
|
||||
* [requests] carries what notifications, widgets and other apps asked to
|
||||
* open. A tapped reminder's occurrence opens the detail overlay exactly like an
|
||||
* event tap; each request is cleared once acted on so a later recomposition
|
||||
* can't re-open it. (A widget event tap instead arrives as
|
||||
* [WidgetNavRequest.OpenEvent], which also roots the back stack in the widget's
|
||||
* view.)
|
||||
* [requestedDetailKey] is an externally requested occurrence (a tapped
|
||||
* reminder notification routed through MainActivity): it opens the detail
|
||||
* overlay exactly like an event tap and is cleared via [onDetailKeyConsumed]
|
||||
* so a later recomposition can't re-open it. (A widget event tap instead arrives
|
||||
* as [WidgetNavRequest.OpenEvent], which also roots the back stack in the
|
||||
* widget's view.)
|
||||
*/
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun CalendarHost(
|
||||
requests: ExternalRequests,
|
||||
modifier: Modifier = Modifier,
|
||||
requestedDetailKey: LongArray? = null,
|
||||
onDetailKeyConsumed: () -> Unit = {},
|
||||
widgetNavRequest: WidgetNavRequest? = null,
|
||||
onWidgetNavConsumed: () -> Unit = {},
|
||||
requestedImportUri: android.net.Uri? = null,
|
||||
onImportConsumed: () -> Unit = {},
|
||||
requestedInsertForm: EventForm? = null,
|
||||
requestedInsertSource: ImportSource = ImportSource.Insert,
|
||||
onInsertConsumed: () -> Unit = {},
|
||||
onExternalFormClosed: () -> Unit = {},
|
||||
requestedEditKey: LongArray? = null,
|
||||
onEditKeyConsumed: () -> Unit = {},
|
||||
viewModel: CalendarHostViewModel = hiltViewModel(),
|
||||
) {
|
||||
// Wait for the persisted default view before seeding the stack, so the app
|
||||
@@ -156,7 +163,7 @@ fun CalendarHost(
|
||||
val view = viewStack.last()
|
||||
// The date every view opens on and carries along (#184); tapping a day
|
||||
// anywhere focuses it and opens the day view there.
|
||||
val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(currentDate()) }
|
||||
val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(localToday()) }
|
||||
// A focus still on yesterday follows the date over, so the next view opened
|
||||
// doesn't land on the old day (#373); one moved elsewhere stays put.
|
||||
val today = viewModel.today.collectAsStateWithLifecycle().value
|
||||
@@ -187,17 +194,27 @@ fun CalendarHost(
|
||||
// it overlays whichever calendar view is active. We forward the tapped
|
||||
// occurrence's own times (eventId + begin + end, packed as a saveable
|
||||
// long[]) so recurring events show the correct date, not the series start.
|
||||
// Like every overlay below, it draws its last key through the slide-out
|
||||
// ([rememberHeld]) rather than a placeholder.
|
||||
// [heldKey] keeps the last shown key alive through the slide-out (when
|
||||
// [detailKey] is cleared); it is set in the tap callback — never a [0,0,0]
|
||||
// placeholder — so the destination never loads a bogus id=0 on first frame.
|
||||
var detailKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
|
||||
val onOpenDetail: (EventInstance) -> Unit = { event -> detailKey = event.occurrenceKey() }
|
||||
var heldKey by remember { mutableStateOf<LongArray?>(null) }
|
||||
val onOpenDetail: (EventInstance) -> Unit = { event ->
|
||||
val key = longArrayOf(
|
||||
event.eventId,
|
||||
event.start.toEpochMilliseconds(),
|
||||
event.end.toEpochMilliseconds(),
|
||||
)
|
||||
heldKey = key
|
||||
detailKey = key
|
||||
}
|
||||
|
||||
// A tapped reminder notification asks for a specific occurrence.
|
||||
val requestedDetailKey = requests.detailKey
|
||||
LaunchedEffect(requestedDetailKey) {
|
||||
if (requestedDetailKey != null) {
|
||||
heldKey = requestedDetailKey
|
||||
detailKey = requestedDetailKey
|
||||
requests.detailKey = null
|
||||
onDetailKeyConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,17 +238,31 @@ fun CalendarHost(
|
||||
// calendar list rather than by preferences. Reached from both.
|
||||
var showBackup by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// Event form (v1.2 create), opened on a date, a tapped slot or a drafted block.
|
||||
var createRequest by rememberSaveable(stateSaver = createRequestSaver) {
|
||||
mutableStateOf<CreateRequest?>(null)
|
||||
// Event form (v1.2 create) — same held-key pattern as the detail screen:
|
||||
// [heldCreateIso] keeps the prefill date alive through the slide-out.
|
||||
// [createStartMinutes] is the tapped slot's start (minutes from midnight)
|
||||
// when the form is opened from a day/week grid tap; null from the FAB.
|
||||
var createDateIso by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var heldCreateIso by remember { mutableStateOf<String?>(null) }
|
||||
var createStartMinutes by rememberSaveable { mutableStateOf<Int?>(null) }
|
||||
var heldCreateMinutes by remember { mutableStateOf<Int?>(null) }
|
||||
// A drafted block's start and end (#153) as epoch millis. Instants rather than
|
||||
// minutes into a day, so the form opens at the times drawn even across a DST
|
||||
// change (#387).
|
||||
var createDraftMillis by rememberSaveable { mutableStateOf<LongArray?>(null) }
|
||||
var heldCreateDraftMillis by remember { mutableStateOf<LongArray?>(null) }
|
||||
fun openCreate(date: LocalDate, startMinutes: Int?, draftMillis: LongArray?) {
|
||||
heldCreateIso = date.toString()
|
||||
createDateIso = date.toString()
|
||||
heldCreateMinutes = startMinutes
|
||||
createStartMinutes = startMinutes
|
||||
heldCreateDraftMillis = draftMillis
|
||||
createDraftMillis = draftMillis
|
||||
}
|
||||
fun openCreate(dateIso: String, startMinutes: Int? = null, draftMillis: LongArray? = null) {
|
||||
createRequest = CreateRequest(dateIso, startMinutes, draftMillis)
|
||||
}
|
||||
val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes -> openCreate(date.toString(), startMinutes) }
|
||||
val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes -> openCreate(date, startMinutes, null) }
|
||||
val onCreateDraft: (Instant, Instant) -> Unit = { start, end ->
|
||||
val date = start.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
openCreate(date.toString(), draftMillis = longArrayOf(start.toEpochMilliseconds(), end.toEpochMilliseconds()))
|
||||
openCreate(date, null, longArrayOf(start.toEpochMilliseconds(), end.toEpochMilliseconds()))
|
||||
}
|
||||
|
||||
// Edit form (v1.3) — reuses the detail screen's occurrence key; for
|
||||
@@ -241,33 +272,25 @@ fun CalendarHost(
|
||||
// falling back to the auto-refreshing calendar is the only honest
|
||||
// destination.
|
||||
var editKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
|
||||
var heldEditKey by remember { mutableStateOf<LongArray?>(null) }
|
||||
|
||||
// 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
|
||||
@@ -283,7 +306,7 @@ fun CalendarHost(
|
||||
importForm = null
|
||||
if (importFromRequest) {
|
||||
importFromRequest = false
|
||||
requests.onExternalFormClosed()
|
||||
onExternalFormClosed()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -291,7 +314,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,64 +325,41 @@ 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++
|
||||
requests.importUri = null
|
||||
onImportConsumed()
|
||||
}
|
||||
}
|
||||
// An external ACTION_INSERT launch (another app/widget creating an event,
|
||||
// issue #30) arrives already prefilled — open it in the same create form the
|
||||
// single-event .ics path uses. [importForm] is the topmost overlay, so it
|
||||
// reveals on top of whatever was open without extra dismissal.
|
||||
val requestedInsert = requests.insert
|
||||
LaunchedEffect(requestedInsert) {
|
||||
if (requestedInsert != null) {
|
||||
if (isReplayOfRestoredImport()) {
|
||||
requests.insert = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
LaunchedEffect(requestedInsertForm) {
|
||||
if (requestedInsertForm != null) {
|
||||
if (importForm == null) {
|
||||
openImportForm(requestedInsert.form, requestedInsert.source, fromRequest = true)
|
||||
openImportForm(requestedInsertForm, requestedInsertSource, fromRequest = true)
|
||||
} else {
|
||||
pendingInsert = requestedInsert.form to requestedInsert.source
|
||||
pendingInsert = requestedInsertForm to requestedInsertSource
|
||||
}
|
||||
requests.insert = null
|
||||
onInsertConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
@@ -367,25 +367,21 @@ fun CalendarHost(
|
||||
// An external "edit this event" (ACTION_EDIT, e.g. an assistant/task app or
|
||||
// widget) opens the occurrence straight in the edit form. Drop any covering
|
||||
// overlay first — the edit overlay sits below Settings/import in the Box, so
|
||||
// without this it would open hidden underneath them. A saved edit just
|
||||
// returns to the calendar.
|
||||
val requestedEditKey = requests.editKey
|
||||
// without this it would open hidden underneath them. Same held-key pattern as
|
||||
// a detail-screen "Edit" tap; a saved edit just returns to the calendar.
|
||||
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()
|
||||
heldEditKey = requestedEditKey
|
||||
editKey = requestedEditKey
|
||||
requests.editKey = null
|
||||
onEditKeyConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
// A home-screen widget launch asks to open a date (→ day view), open an
|
||||
// event's detail, or start a create. Handled once and cleared, mirroring
|
||||
// the detail request. Date/event opens root the stack in the widget's own
|
||||
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
|
||||
// view so backing out returns there (then home), not to the default.
|
||||
val widgetNavRequest = requests.nav
|
||||
LaunchedEffect(widgetNavRequest) {
|
||||
if (widgetNavRequest != null) {
|
||||
navigationVersion++
|
||||
@@ -396,19 +392,23 @@ 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()
|
||||
createDateIso = 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.
|
||||
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay()
|
||||
requests.nav = null
|
||||
onWidgetNavConsumed()
|
||||
}
|
||||
is WidgetNavRequest.OpenEvent -> {
|
||||
// Root the stack in the widget's view, then open the occurrence
|
||||
// detail over it (same key shape as a tapped event / reminder).
|
||||
dismissCoveringOverlays()
|
||||
createDateIso = null
|
||||
viewStack = viewBaseStack(defaultView, req.source)
|
||||
detailKey = longArrayOf(req.eventId, req.beginMillis, req.endMillis)
|
||||
requests.nav = null
|
||||
val key = longArrayOf(req.eventId, req.beginMillis, req.endMillis)
|
||||
heldKey = key
|
||||
detailKey = key
|
||||
onWidgetNavConsumed()
|
||||
}
|
||||
is WidgetNavRequest.OpenView -> {
|
||||
// A widget header tap: land on a top-level view with no date
|
||||
@@ -416,19 +416,26 @@ 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()
|
||||
focus.navigateTo(currentDate())
|
||||
createDateIso = null
|
||||
focus.navigateTo(localToday())
|
||||
viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
|
||||
requests.nav = null
|
||||
onWidgetNavConsumed()
|
||||
}
|
||||
is WidgetNavRequest.Create -> {
|
||||
// External "new event" entries (QS tile / launcher shortcut /
|
||||
// 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
|
||||
val iso = req.dateIso ?: Clock.System.now()
|
||||
.toLocalDateTime(TimeZone.currentSystemDefault()).date.toString()
|
||||
heldCreateIso = iso
|
||||
createDateIso = iso
|
||||
heldCreateMinutes = null
|
||||
createStartMinutes = null
|
||||
heldCreateDraftMillis = null
|
||||
createDraftMillis = null
|
||||
onWidgetNavConsumed()
|
||||
}
|
||||
null -> {}
|
||||
}
|
||||
@@ -441,28 +448,23 @@ fun CalendarHost(
|
||||
val eventActions: EventActionsViewModel = hiltViewModel()
|
||||
val moveOutcome by reschedule.outcome.collectAsStateWithLifecycle()
|
||||
val selectionFailure by eventActions.failure.collectAsStateWithLifecycle()
|
||||
val onEditEvent: (EventInstance) -> Unit = { event -> editKey = event.occurrenceKey() }
|
||||
val onEditEvent: (EventInstance) -> Unit = { event ->
|
||||
val key = longArrayOf(
|
||||
event.eventId,
|
||||
event.start.toEpochMilliseconds(),
|
||||
event.end.toEpochMilliseconds(),
|
||||
)
|
||||
heldEditKey = key
|
||||
editKey = key
|
||||
}
|
||||
// A tap opens the edit form instead of the detail screen when the user chose
|
||||
// so (#178) — only for events whose calendar takes writes. Both are read at
|
||||
// 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 +472,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -492,10 +494,15 @@ fun CalendarHost(
|
||||
)
|
||||
}
|
||||
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
// Overlays leave from the predictive-back preview (#184): shrinking on from
|
||||
// where the gesture let go instead of snapping back and sliding off.
|
||||
val overlayExit = predictiveBackExit()
|
||||
|
||||
// Base-level back: pop the view stack while no overlay covers it (each overlay
|
||||
// owns its own BackHandler and takes precedence). Disabled at the home view,
|
||||
// so back there falls through to the system and exits the app.
|
||||
val anyOverlayVisible = showSearch || detailKey != null || createRequest != null ||
|
||||
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
|
||||
editKey != null || showSettings || showCalendars || showBackup ||
|
||||
importUri != null || importForm != null
|
||||
// The view switch runs on a seekable transition, so a back gesture can scrub
|
||||
@@ -689,7 +696,11 @@ fun CalendarHost(
|
||||
|
||||
// Search overlay — below detail/edit in the Box so a tapped result's
|
||||
// detail screen draws on top, and closing it returns to the results.
|
||||
SlideOverlay(visible = showSearch) {
|
||||
AnimatedVisibility(
|
||||
visible = showSearch,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
SearchScreen(
|
||||
onBack = { showSearch = false },
|
||||
onEventClick = onEventClick,
|
||||
@@ -697,15 +708,23 @@ fun CalendarHost(
|
||||
)
|
||||
}
|
||||
|
||||
val shownDetailKey = rememberHeld(detailKey)
|
||||
SlideOverlay(visible = detailKey != null) {
|
||||
shownDetailKey?.let { key ->
|
||||
// Prefer the live key; fall back to the held one only while sliding out.
|
||||
val activeKey = detailKey ?: heldKey
|
||||
AnimatedVisibility(
|
||||
visible = detailKey != null,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
activeKey?.let { key ->
|
||||
EventDetailScreen(
|
||||
eventId = key[0],
|
||||
beginMillis = key[1],
|
||||
endMillis = key[2],
|
||||
onBack = { detailKey = null },
|
||||
onEdit = { editKey = key },
|
||||
onEdit = {
|
||||
heldEditKey = key
|
||||
editKey = key
|
||||
},
|
||||
onDuplicate = { form ->
|
||||
// Reuse the prefilled-create overlay: a duplicate is a
|
||||
// fresh event, so it applies the default reminder like an
|
||||
@@ -719,28 +738,32 @@ fun CalendarHost(
|
||||
}
|
||||
|
||||
// Event form (v1.2) — full-screen destination, slides over the calendar.
|
||||
val shownCreate = rememberHeld(createRequest)
|
||||
SlideOverlay(visible = createRequest != null, onHidden = createViewModel::reset) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = createDateIso != null,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
(createDateIso ?: heldCreateIso)?.let { iso ->
|
||||
EventEditScreen(
|
||||
initialDateIso = iso,
|
||||
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
|
||||
initialDraft = createDraftMillis ?: heldCreateDraftMillis,
|
||||
onClose = { createDateIso = null },
|
||||
onSaved = { createDateIso = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
visible = createDateIso != 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) {
|
||||
shownEditKey?.let { key ->
|
||||
AnimatedVisibility(
|
||||
visible = editKey != null,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
(editKey ?: heldEditKey)?.let { key ->
|
||||
EventEditScreen(
|
||||
initialDateIso = null,
|
||||
editKey = key,
|
||||
@@ -757,16 +780,23 @@ fun CalendarHost(
|
||||
// below to show why, so open it.
|
||||
onLoadFailed = {
|
||||
editKey = null
|
||||
if (detailKey == null) detailKey = key
|
||||
if (detailKey == null) {
|
||||
heldKey = key
|
||||
detailKey = key
|
||||
}
|
||||
},
|
||||
visible = editKey != null,
|
||||
viewModel = editViewModel,
|
||||
viewModel = hiltViewModel(key = "edit"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Settings (M4) — full-screen destination, slides over the calendar.
|
||||
SlideOverlay(visible = showSettings) {
|
||||
AnimatedVisibility(
|
||||
visible = showSettings,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
SettingsScreen(
|
||||
onBack = { showSettings = false },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
@@ -824,7 +854,11 @@ fun CalendarHost(
|
||||
|
||||
// Declared last so it covers every overlay that can open it: Settings,
|
||||
// both event forms, and the .ics import picker (#76).
|
||||
SlideOverlay(visible = showCalendars) {
|
||||
AnimatedVisibility(
|
||||
visible = showCalendars,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
CalendarsScreen(
|
||||
onBack = { showCalendars = false },
|
||||
onOpenBackup = { showBackup = true },
|
||||
@@ -833,7 +867,11 @@ fun CalendarHost(
|
||||
}
|
||||
|
||||
// Backup & restore — over the manager, since the manager links into it.
|
||||
SlideOverlay(visible = showBackup) {
|
||||
AnimatedVisibility(
|
||||
visible = showBackup,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
// Settings can open this screen without the manager underneath, so
|
||||
// its failure states' "Manage calendars" way out has to open the
|
||||
// manager rather than just pop — popping alone lands on Settings
|
||||
@@ -872,48 +910,5 @@ private val viewStackSaver = listSaver<List<CalendarView>, Int>(
|
||||
restore = { ordinals -> ordinals.map { CalendarView.entries[it] } },
|
||||
)
|
||||
|
||||
/**
|
||||
* An overlay's slide-in from the end edge, leaving from the predictive-back
|
||||
* preview (#184): shrinking on from where the gesture let go instead of
|
||||
* 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()
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visibleState = state,
|
||||
enter = slideInHorizontally(rememberCalendarSlideSpec()) { it } + fadeIn(),
|
||||
exit = predictiveBackExit(),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
/** The detail and edit overlays' key for one occurrence: eventId, begin, end. */
|
||||
private fun EventInstance.occurrenceKey(): LongArray =
|
||||
longArrayOf(eventId, start.toEpochMilliseconds(), end.toEpochMilliseconds())
|
||||
|
||||
/**
|
||||
* A create-form open: its date, plus the tapped slot's start (minutes from
|
||||
* midnight) from a day/week grid tap, or a drafted block's start and end (#153)
|
||||
* as epoch millis. Instants rather than minutes into a day, so the form opens at
|
||||
* the times drawn even across a DST change (#387).
|
||||
*/
|
||||
private class CreateRequest(
|
||||
val dateIso: String,
|
||||
val startMinutes: Int?,
|
||||
val draftMillis: LongArray?,
|
||||
)
|
||||
|
||||
private val createRequestSaver = Saver<CreateRequest?, List<Any?>>(
|
||||
save = { request -> request?.let { listOf(it.dateIso, it.startMinutes, it.draftMillis) } },
|
||||
restore = { CreateRequest(it[0] as String, it[1] as Int?, it[2] as LongArray?) },
|
||||
)
|
||||
private fun localToday(): LocalDate =
|
||||
Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
|
||||
|
||||
/**
|
||||
* What a notification, widget, shortcut or another app asked the running app to
|
||||
* open. MainActivity fills it from each intent (singleTop + onNewIntent route a
|
||||
* new one into the running activity); CalendarHost acts on a request and clears
|
||||
* it, so a later recomposition can't act on it twice.
|
||||
*
|
||||
* [onExternalFormClosed] runs when a form opened for [insert] closes, so the
|
||||
* activity can hand control back to the app that asked.
|
||||
*/
|
||||
@Stable
|
||||
class ExternalRequests(val onExternalFormClosed: () -> Unit = {}) {
|
||||
/** A tapped reminder notification's occurrence (eventId, begin, end), for the detail screen. */
|
||||
var detailKey by mutableStateOf<LongArray?>(null)
|
||||
|
||||
/** A home-screen widget, shortcut or tile navigation (open a date, an event, a view, or create). */
|
||||
var nav by mutableStateOf<WidgetNavRequest?>(null)
|
||||
|
||||
/** An .ics file opened or shared into the app (ACTION_VIEW/SEND). */
|
||||
var importUri by mutableStateOf<Uri?>(null)
|
||||
|
||||
/**
|
||||
* A prefilled new-event form from an external ACTION_INSERT launch (#30) or
|
||||
* shared text (#306), opened in the create form for review.
|
||||
*/
|
||||
var insert by mutableStateOf<InsertRequest?>(null)
|
||||
|
||||
/** 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. */
|
||||
data class InsertRequest(val form: EventForm, val source: ImportSource)
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui
|
||||
|
||||
import android.Manifest
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
@@ -22,13 +23,13 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
||||
import de.jeanlucmakiola.calendula.ui.common.canReadCalendar
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingStep
|
||||
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingSteps
|
||||
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingViewModel
|
||||
import de.jeanlucmakiola.calendula.ui.onboarding.onboardingSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.onboarding.rememberOnboardingSlideSpec
|
||||
import de.jeanlucmakiola.floret.android.hasPermission
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
|
||||
/** What the root is showing: a wizard step, the app, or neither yet. */
|
||||
@@ -41,12 +42,26 @@ private sealed interface RootTarget {
|
||||
|
||||
@Composable
|
||||
fun RootScreen(
|
||||
requests: ExternalRequests,
|
||||
modifier: Modifier = Modifier,
|
||||
requestedDetailKey: LongArray? = null,
|
||||
onDetailKeyConsumed: () -> Unit = {},
|
||||
widgetNavRequest: WidgetNavRequest? = null,
|
||||
onWidgetNavConsumed: () -> Unit = {},
|
||||
requestedImportUri: android.net.Uri? = null,
|
||||
onImportConsumed: () -> Unit = {},
|
||||
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
|
||||
requestedInsertSource: de.jeanlucmakiola.calendula.ui.edit.ImportSource =
|
||||
de.jeanlucmakiola.calendula.ui.edit.ImportSource.Insert,
|
||||
onInsertConsumed: () -> Unit = {},
|
||||
onExternalFormClosed: () -> Unit = {},
|
||||
requestedEditKey: LongArray? = null,
|
||||
onEditKeyConsumed: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var hasPermission by remember {
|
||||
mutableStateOf(context.canReadCalendar())
|
||||
mutableStateOf(
|
||||
context.hasPermission(Manifest.permission.READ_CALENDAR)
|
||||
)
|
||||
}
|
||||
|
||||
// A launch scan already covers the app coming up with the permission, so
|
||||
@@ -57,7 +72,7 @@ fun RootScreen(
|
||||
DisposableEffect(lifecycle) {
|
||||
val obs = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
hasPermission = context.canReadCalendar()
|
||||
hasPermission = context.hasPermission(Manifest.permission.READ_CALENDAR)
|
||||
// Refresh the contact special-dates mirror on foreground (the
|
||||
// worker is debounced and no-ops when the feature is off). Gated
|
||||
// on the permission so users without the opt-in never enqueue it.
|
||||
@@ -127,8 +142,19 @@ fun RootScreen(
|
||||
when (shown) {
|
||||
RootTarget.Loading -> Unit
|
||||
RootTarget.App -> CalendarHost(
|
||||
requests = requests,
|
||||
modifier = modifier,
|
||||
requestedDetailKey = requestedDetailKey,
|
||||
onDetailKeyConsumed = onDetailKeyConsumed,
|
||||
widgetNavRequest = widgetNavRequest,
|
||||
onWidgetNavConsumed = onWidgetNavConsumed,
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = onImportConsumed,
|
||||
requestedInsertForm = requestedInsertForm,
|
||||
requestedInsertSource = requestedInsertSource,
|
||||
onInsertConsumed = onInsertConsumed,
|
||||
onExternalFormClosed = onExternalFormClosed,
|
||||
requestedEditKey = requestedEditKey,
|
||||
onEditKeyConsumed = onEditKeyConsumed,
|
||||
)
|
||||
// Both halves of a transition read their position out of the live
|
||||
// plan; the one the outgoing step was drawn with is already gone.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -10,7 +10,6 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||
import de.jeanlucmakiola.calendula.data.time.TodaySource
|
||||
import de.jeanlucmakiola.calendula.data.time.currentDate
|
||||
import de.jeanlucmakiola.calendula.data.time.stateFollowingDays
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
@@ -27,6 +26,7 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
@@ -98,14 +98,21 @@ class AgendaViewModel @Inject constructor(
|
||||
var lastTopDate: LocalDate? = null
|
||||
private set
|
||||
|
||||
private var lastToday = todayDate
|
||||
|
||||
/**
|
||||
* Today, kept current across midnight (#373). An agenda still starting on
|
||||
* yesterday moves on to the new day; one jumped elsewhere stays put.
|
||||
*/
|
||||
private val today: StateFlow<LocalDate> =
|
||||
todaySource.today.stateFollowingDays(viewModelScope, todayDate) { old, date ->
|
||||
if (_window.value.anchor == old) _window.value = _window.value.copy(anchor = date)
|
||||
private val today: StateFlow<LocalDate> = todaySource.today
|
||||
.onEach { date ->
|
||||
val old = lastToday
|
||||
lastToday = date
|
||||
if (old != date && _window.value.anchor == old) {
|
||||
_window.value = _window.value.copy(anchor = date)
|
||||
}
|
||||
}
|
||||
.stateWhileSubscribed(viewModelScope, todayDate)
|
||||
|
||||
// A transient, in-view override of the saved agenda range. Held in memory
|
||||
// (not persisted), so it survives view switches and rotation within the
|
||||
@@ -163,17 +170,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 +240,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
|
||||
@@ -73,10 +72,6 @@ suspend fun CalendarRepository.deleteEvent(
|
||||
/** How a [tryDeleteEvent] ended. */
|
||||
enum class DeleteOutcome { Deleted, Gone, NeedsPermission, Failed }
|
||||
|
||||
/** Whether this install may read the calendar at all. */
|
||||
fun Context.canReadCalendar(): Boolean =
|
||||
hasPermission(Manifest.permission.READ_CALENDAR)
|
||||
|
||||
/** Whether this install may write to the calendar; v1.0 installs only hold read access. */
|
||||
fun Context.canWriteCalendar(): Boolean =
|
||||
hasPermission(Manifest.permission.WRITE_CALENDAR)
|
||||
@@ -101,28 +96,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
|
||||
}
|
||||
@@ -390,20 +390,17 @@ internal fun resizedSpan(
|
||||
}
|
||||
if (wrongWay) return startMin to endMin
|
||||
}
|
||||
val snapped = snapToSlot(edgeMin)
|
||||
val snapped = (edgeMin / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
|
||||
return when (edge) {
|
||||
ResizeEdge.Top -> minOf(snapped, endMin - DRAG_SNAP_MINUTES) to endMin
|
||||
ResizeEdge.Bottom -> startMin to maxOf(snapped, startMin + DRAG_SNAP_MINUTES)
|
||||
}
|
||||
}
|
||||
|
||||
/** [minutes] rounded to the nearest [DRAG_SNAP_MINUTES] slot. */
|
||||
internal fun snapToSlot(minutes: Float): Int = (minutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
|
||||
|
||||
/** [minutes] snapped, kept at [held] until three quarters of a step away. */
|
||||
internal fun stickySnap(minutes: Float, held: Int?): Int {
|
||||
if (held != null && kotlin.math.abs(minutes - held) < DRAG_SNAP_MINUTES * 0.75f) return held
|
||||
return snapToSlot(minutes)
|
||||
return (minutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
|
||||
}
|
||||
|
||||
/** How far past its column's side the finger has to go before a resize changes day. */
|
||||
@@ -626,8 +623,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 +688,10 @@ class TimelineDragController {
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
resize = null
|
||||
resizeEdge = null
|
||||
resizeDay = null
|
||||
resizeColumn = null
|
||||
resizeSlot = null
|
||||
source = null
|
||||
clipOffsetMin = 0
|
||||
dragZone = null
|
||||
@@ -749,12 +766,6 @@ class TimelineDragController {
|
||||
if (landed == null || landed.slot == origin) return null
|
||||
// A draft dropped on a day off screen would vanish, so it stays where it was (#153).
|
||||
if (landed.edgeOnly && landed.event.isDraft) return null
|
||||
settle(landed)
|
||||
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset)
|
||||
}
|
||||
|
||||
/** Keep [landed]'s copy on the target as [settling] until [release]; [end] is a resize's. */
|
||||
private fun settle(landed: TimelineDrag, end: Instant? = null) {
|
||||
settlingSelected = selection?.event?.instanceId == landed.event.instanceId
|
||||
// An edge piece is a hint for the finger, not a preview of the landing
|
||||
// slot: the event belongs to a day this timeline has no column for, so
|
||||
@@ -764,8 +775,8 @@ class TimelineDragController {
|
||||
liftedInstanceId = landed.event.instanceId
|
||||
settledOrigin = landed.event.eventId to landed.event.start
|
||||
settledAbandonedMark = abandonedTicks?.value ?: 0
|
||||
settledEnd = end
|
||||
drops++
|
||||
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -780,9 +791,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 +804,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 }
|
||||
?.let { column(it) }
|
||||
session.grabMin = (pointerResizeMinutes(session) ?: 0f) -
|
||||
if (edge == ResizeEdge.Top) session.startMin else session.endMin
|
||||
resizeColumn = edgeIndex.takeIf { it >= 0 }
|
||||
?.let { if (geometry.isRtl) geometry.days.lastIndex - it else it }
|
||||
resizeGrabMin = (pointerResizeMinutes() ?: 0f) -
|
||||
if (edge == ResizeEdge.Top) resizeStartMin else resizeEndMin
|
||||
originSlot = null
|
||||
recompute()
|
||||
originSlot = drag?.slot
|
||||
@@ -812,7 +825,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,11 +841,17 @@ 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)
|
||||
settle(landed, end = drop.end)
|
||||
settlingSelected = selection?.event?.instanceId == landed.event.instanceId
|
||||
settling = if (landed.edgeOnly) landed.copy(pieces = emptyList()) else landed
|
||||
liftedInstanceId = landed.event.instanceId
|
||||
settledOrigin = landed.event.eventId to landed.event.start
|
||||
settledAbandonedMark = abandonedTicks?.value ?: 0
|
||||
settledEnd = drop.end
|
||||
drops++
|
||||
return drop
|
||||
}
|
||||
|
||||
@@ -840,33 +859,36 @@ 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
|
||||
if (hourPx <= 0f || columnPx <= 0f || days.isEmpty()) return null
|
||||
val origin = grid.positionInRoot()
|
||||
val held = stickyColumn(
|
||||
val column = 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 = column
|
||||
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
|
||||
val dayOffset = (days[dayIndex].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 hourPx = geometry.hourPx
|
||||
val columnPx = geometry.columnWidthPx
|
||||
val origin = geometry.grid?.takeIf { it.isAttached }?.positionInRoot() ?: return
|
||||
// Which column the start day sits in, possibly off either end of the view.
|
||||
val startIndex = (day.toEpochDays() - days.first().toEpochDays()).toInt()
|
||||
@@ -879,7 +901,20 @@ class TimelineDragController {
|
||||
eventStartMin = start,
|
||||
eventSpanMin = end - start,
|
||||
titleLines = titleLines,
|
||||
pieces = pieces(slices, startIndex, origin),
|
||||
pieces = slices.map { slice ->
|
||||
val dayIndex = startIndex + slice.dayOffset
|
||||
val col = if (geometry.isRtl) days.lastIndex - dayIndex else dayIndex
|
||||
val bounds = dragPieceBounds(slice, hourPx, geometry.blockInsetPx)
|
||||
TimelineDragPiece(
|
||||
topLeftInRoot = Offset(x = origin.x + col * columnPx, y = origin.y + bounds.top),
|
||||
sizePx = IntSize(
|
||||
(columnPx - geometry.columnGapPx).roundToInt(),
|
||||
bounds.height.roundToInt(),
|
||||
),
|
||||
continuesBefore = slice.continuesBefore,
|
||||
continuesAfter = slice.continuesAfter,
|
||||
)
|
||||
},
|
||||
edgeOnly = slices.isEmpty(),
|
||||
markerMin = if (edge == ResizeEdge.Top) {
|
||||
start.mod(MINUTES_PER_DAY)
|
||||
@@ -924,7 +959,7 @@ class TimelineDragController {
|
||||
* no move events while auto-scroll walks the content underneath it.
|
||||
*/
|
||||
fun recompute() {
|
||||
resize?.let {
|
||||
resizeEdge?.let {
|
||||
recomputeResize(it)
|
||||
return
|
||||
}
|
||||
@@ -937,9 +972,10 @@ class TimelineDragController {
|
||||
|
||||
val origin = grid.positionInRoot()
|
||||
val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f
|
||||
val snapped = snapToSlot(rawMinutes)
|
||||
val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
|
||||
val zone = dragZone ?: return
|
||||
val dayIndex = column(((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex))
|
||||
val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex)
|
||||
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
|
||||
val target = days[dayIndex]
|
||||
val keep = dragKept
|
||||
// Measured where it would land: the drop keeps the real duration, which in
|
||||
@@ -967,34 +1003,28 @@ class TimelineDragController {
|
||||
titleLines = titleLines,
|
||||
// Bounded to the columns this timeline actually shows: a day it
|
||||
// doesn't has no piece to draw. The write is unaffected.
|
||||
pieces = pieces(slices, dayIndex, origin),
|
||||
pieces = slices
|
||||
.map { slice ->
|
||||
val day = dayIndex + slice.dayOffset
|
||||
val col = if (geometry.isRtl) days.lastIndex - day else day
|
||||
val bounds = dragPieceBounds(slice, hourPx, geometry.blockInsetPx)
|
||||
TimelineDragPiece(
|
||||
topLeftInRoot = Offset(
|
||||
x = origin.x + col * columnPx,
|
||||
y = origin.y + bounds.top,
|
||||
),
|
||||
sizePx = IntSize(
|
||||
(columnPx - geometry.columnGapPx).roundToInt(),
|
||||
bounds.height.roundToInt(),
|
||||
),
|
||||
continuesBefore = slice.continuesBefore,
|
||||
continuesAfter = slice.continuesAfter,
|
||||
)
|
||||
},
|
||||
edgeOnly = onGrid.isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
/** [slices] as floating pieces, the first slice's day being [dayIndex] in the shown days. */
|
||||
private fun pieces(slices: List<DragSlice>, dayIndex: Int, origin: Offset): List<TimelineDragPiece> {
|
||||
val columnPx = geometry.columnWidthPx
|
||||
return slices.map { slice ->
|
||||
val bounds = dragPieceBounds(slice, geometry.hourPx, geometry.blockInsetPx)
|
||||
TimelineDragPiece(
|
||||
topLeftInRoot = Offset(
|
||||
x = origin.x + column(dayIndex + slice.dayOffset) * columnPx,
|
||||
y = origin.y + bounds.top,
|
||||
),
|
||||
sizePx = IntSize(
|
||||
(columnPx - geometry.columnGapPx).roundToInt(),
|
||||
bounds.height.roundToInt(),
|
||||
),
|
||||
continuesBefore = slice.continuesBefore,
|
||||
continuesAfter = slice.continuesAfter,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The on-screen column of a day index, and back: right-to-left lays the days out mirrored. */
|
||||
private fun column(index: Int): Int = if (geometry.isRtl) geometry.days.lastIndex - index else index
|
||||
|
||||
/**
|
||||
* Hold the scroll for the whole drag and nudge it once per frame while the
|
||||
* finger sits near an edge. One `scroll` call, since a loop of
|
||||
@@ -1027,27 +1057,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() }
|
||||
|
||||
@@ -84,6 +84,7 @@ import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.InlineStyle
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.domain.RichText
|
||||
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
|
||||
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
||||
@@ -95,7 +96,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 +170,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)
|
||||
@@ -299,7 +303,7 @@ private fun EventDetailContent(
|
||||
val detail = state.detail
|
||||
val instance = detail.instance
|
||||
val dark = LocalFloretDarkTheme.current
|
||||
val locale = currentLocale()
|
||||
val locale = currentDetailLocale()
|
||||
val accent = eventAccent(instance.color, dark, LocalSoftenColors.current)
|
||||
|
||||
Column(
|
||||
@@ -532,7 +536,7 @@ private fun EventDetailContent(
|
||||
.sortedBy { it.minutes }
|
||||
.forEach { reminder ->
|
||||
Text(
|
||||
text = reminderLeadTimeLabel(reminder.minutes),
|
||||
text = reminderLeadText(reminder),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
@@ -679,6 +683,11 @@ private fun SkeletonBar(widthFraction: Float, height: Dp) {
|
||||
|
||||
// --- helpers -------------------------------------------------------------
|
||||
|
||||
// Observable locale read (shared helper) — avoids NonObservableLocale /
|
||||
// LocalContextConfigurationRead lint by going through LocalConfiguration.
|
||||
@Composable
|
||||
private fun currentDetailLocale(): Locale = currentLocale()
|
||||
|
||||
private fun attendeeStatusLabel(status: AttendeeStatus): Int = when (status) {
|
||||
AttendeeStatus.Accepted -> R.string.event_attendee_accepted
|
||||
AttendeeStatus.Declined -> R.string.event_attendee_declined
|
||||
@@ -698,6 +707,10 @@ private fun attendeeRoleLabel(attendee: Attendee): Int? = when {
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Humanise a reminder's lead time, e.g. "10 minutes before" / "At time of event". */
|
||||
@Composable
|
||||
private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel(reminder.minutes)
|
||||
|
||||
/**
|
||||
* [tz] resolved as a [TimeZoneOption], but only when the event is timed and
|
||||
* pinned to a zone different from the device's — the cases where showing it
|
||||
|
||||
@@ -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 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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*
|
||||
|
||||
@@ -10,7 +10,6 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||
import de.jeanlucmakiola.calendula.data.time.TodaySource
|
||||
import de.jeanlucmakiola.calendula.data.time.currentDate
|
||||
import de.jeanlucmakiola.calendula.data.time.stateFollowingDays
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
@@ -91,9 +90,16 @@ class MonthViewModel @Inject constructor(
|
||||
private val todayDate: LocalDate
|
||||
get() = currentDate()
|
||||
|
||||
private var lastToday = todayDate
|
||||
|
||||
/** Today, kept current across midnight so a grid left open doesn't mark yesterday (#373). */
|
||||
val today: StateFlow<LocalDate> =
|
||||
todaySource.today.stateFollowingDays(viewModelScope, todayDate, ::followToday)
|
||||
val today: StateFlow<LocalDate> = todaySource.today
|
||||
.onEach { date ->
|
||||
val old = lastToday
|
||||
lastToday = date
|
||||
if (old != date) followToday(old, date)
|
||||
}
|
||||
.stateWhileSubscribed(viewModelScope, todayDate)
|
||||
|
||||
private val _month = MutableStateFlow(todayDate.yearMonth)
|
||||
val month: StateFlow<YearMonth> = _month
|
||||
|
||||
@@ -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,47 +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.config.WidgetKind
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import de.jeanlucmakiola.calendula.widget.config.backgroundLabel
|
||||
import de.jeanlucmakiola.calendula.widget.config.label
|
||||
import de.jeanlucmakiola.calendula.widget.config.placedWidgets
|
||||
import de.jeanlucmakiola.calendula.widget.config.themeLabel
|
||||
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.
|
||||
@@ -69,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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -138,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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineResizeDrop
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* What every timeline page hands down to its day columns: the shared vertical
|
||||
* scroll, the drag controller, and the callbacks. One instance per screen, so
|
||||
* passing it never makes a page recompose.
|
||||
*/
|
||||
@Stable
|
||||
internal class TimelineActions(
|
||||
val scrollState: ScrollState,
|
||||
val dragController: TimelineDragController,
|
||||
val onEventClick: (EventInstance) -> Unit,
|
||||
val onOpenDay: (LocalDate) -> Unit,
|
||||
val onCreateAt: (LocalDate, Int) -> Unit,
|
||||
val onDrop: (TimelineDrop) -> Unit,
|
||||
val onResize: (TimelineResizeDrop) -> Unit,
|
||||
)
|
||||
|
||||
/** The callbacks are read through their latest values, so the holder outlives them. */
|
||||
@Composable
|
||||
internal fun rememberTimelineActions(
|
||||
scrollState: ScrollState,
|
||||
dragController: TimelineDragController,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
onResize: (TimelineResizeDrop) -> Unit,
|
||||
): TimelineActions {
|
||||
val eventClick = rememberUpdatedState(onEventClick)
|
||||
val openDay = rememberUpdatedState(onOpenDay)
|
||||
val createAt = rememberUpdatedState(onCreateAt)
|
||||
val drop = rememberUpdatedState(onDrop)
|
||||
val resize = rememberUpdatedState(onResize)
|
||||
return remember(scrollState, dragController) {
|
||||
TimelineActions(
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
onEventClick = { eventClick.value(it) },
|
||||
onOpenDay = { openDay.value(it) },
|
||||
onCreateAt = { date, minutes -> createAt.value(date, minutes) },
|
||||
onDrop = { drop.value(it) },
|
||||
onResize = { resize.value(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -39,6 +39,7 @@ import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -70,7 +71,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 +465,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) }
|
||||
@@ -516,7 +516,7 @@ private fun PagedTimelineScreen(
|
||||
onRetry = viewModel::goToToday,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateEvent,
|
||||
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
||||
onCreateDraft = onCreateDraft,
|
||||
draftMinutes = draftMinutes,
|
||||
modifier = Modifier
|
||||
@@ -532,7 +532,7 @@ private fun WeekContent(
|
||||
state: WeekUiState,
|
||||
today: LocalDate,
|
||||
pagerState: PagerState,
|
||||
grid: DaysGrid,
|
||||
grid: PageGrid,
|
||||
kind: TimelineKind,
|
||||
morph: TimelineMorph?,
|
||||
handoffStrip: Dp?,
|
||||
@@ -638,31 +638,16 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
val actions = rememberTimelineActions(
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
onEventClick = openEvent,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
onResize = onResize,
|
||||
)
|
||||
|
||||
Box(modifier = modifier) {
|
||||
CompositionLocalProvider(LocalOpeningTimeline provides timelineScroll) {
|
||||
// Loading and failure concern the provider as a whole, so they stand in
|
||||
@@ -678,7 +663,17 @@ private fun WeekContent(
|
||||
WeekUiState.Loading -> WeekLoading(dayCount = grid.dayCount)
|
||||
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||
is WeekUiState.Success -> if (morph != null) {
|
||||
TimelineMorphFrame(morph = morph, today = today, actions = actions)
|
||||
TimelineMorphFrame(
|
||||
morph = morph,
|
||||
today = today,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
onEventClick = openEvent,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
onResize = onResize,
|
||||
)
|
||||
} else WeekPager(
|
||||
pagerState = pagerState,
|
||||
grid = grid,
|
||||
@@ -686,7 +681,13 @@ private fun WeekContent(
|
||||
span = span,
|
||||
today = today,
|
||||
initialStripHeight = handoffStrip ?: s.allDayStripHeight(),
|
||||
actions = actions,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
onEventClick = openEvent,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
onResize = onResize,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -702,21 +703,27 @@ private fun WeekContent(
|
||||
@Composable
|
||||
private fun WeekPager(
|
||||
pagerState: PagerState,
|
||||
grid: DaysGrid,
|
||||
grid: PageGrid,
|
||||
kind: TimelineKind,
|
||||
span: (DaySpan) -> StateFlow<WeekUiState>,
|
||||
today: LocalDate,
|
||||
initialStripHeight: Dp,
|
||||
actions: TimelineActions,
|
||||
scrollState: ScrollState,
|
||||
dragController: TimelineDragController,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
onResize: (TimelineResizeDrop) -> Unit,
|
||||
) {
|
||||
val dragController = actions.dragController
|
||||
val stripHeights = remember { mutableStateMapOf<Int, Dp>() }
|
||||
val allDayHeight = pagedHeight(pagerState, stripHeights, initialStripHeight)
|
||||
WeekFrame(
|
||||
weekStart = grid.startForPage(pagerState.currentPage),
|
||||
showWeekNumber = kind.showWeekNumber,
|
||||
allDayHeight = allDayHeight,
|
||||
actions = actions,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
@@ -749,8 +756,14 @@ private fun WeekPager(
|
||||
},
|
||||
headerBias = kind.headerBias,
|
||||
allDayHeight = allDayHeight,
|
||||
actions = actions,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
active = page == pagerState.currentPage,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
onResize = onResize,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -779,7 +792,13 @@ private fun emptySpan(span: DaySpan, today: LocalDate): WeekUiState.Success {
|
||||
private fun TimelineMorphFrame(
|
||||
morph: TimelineMorph,
|
||||
today: LocalDate,
|
||||
actions: TimelineActions,
|
||||
scrollState: ScrollState,
|
||||
dragController: TimelineDragController,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
onResize: (TimelineResizeDrop) -> Unit,
|
||||
) {
|
||||
val fromState by morph.fromState.collectAsStateWithLifecycle()
|
||||
val toState by morph.toState.collectAsStateWithLifecycle()
|
||||
@@ -817,7 +836,8 @@ private fun TimelineMorphFrame(
|
||||
allDayHeight = fromStrip,
|
||||
animatedAllDayHeight = animatedStrip,
|
||||
weekNumberAlpha = badgeAlpha,
|
||||
actions = actions,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
) {
|
||||
TimelinePageContent(
|
||||
geometry = geometry,
|
||||
@@ -827,8 +847,14 @@ private fun TimelineMorphFrame(
|
||||
headerBias = headerBias,
|
||||
allDayHeight = fromStrip,
|
||||
animatedAllDayHeight = animatedStrip,
|
||||
actions = actions,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
active = false,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
onResize = onResize,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -862,7 +888,8 @@ private fun WeekFrame(
|
||||
allDayHeight: Dp,
|
||||
animatedAllDayHeight: (() -> Dp)? = null,
|
||||
weekNumberAlpha: () -> Float = { 1f },
|
||||
actions: TimelineActions,
|
||||
scrollState: ScrollState,
|
||||
dragController: TimelineDragController,
|
||||
pages: @Composable () -> Unit,
|
||||
) {
|
||||
val gutterFullHeight = remember(animatedAllDayHeight) { intArrayOf(-1) }
|
||||
@@ -896,10 +923,10 @@ private fun WeekFrame(
|
||||
else maxHeight
|
||||
val hourHeight = zoom.scale.hourHeight(fitHeight)
|
||||
HourGutter(
|
||||
scrollState = actions.scrollState,
|
||||
scrollState = scrollState,
|
||||
hourHeight = hourHeight,
|
||||
dragController = actions.dragController,
|
||||
modifier = rememberTimelinePinchZoom(actions.scrollState, fitHeight, hourHeight, zoom),
|
||||
dragController = dragController,
|
||||
modifier = rememberTimelinePinchZoom(scrollState, fitHeight, hourHeight, zoom),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -914,21 +941,34 @@ internal fun WeekSuccess(
|
||||
state: WeekUiState.Success,
|
||||
showWeekNumber: Boolean = true,
|
||||
headerBias: Float = 0f,
|
||||
scrollState: ScrollState,
|
||||
allDayHeight: Dp,
|
||||
actions: TimelineActions,
|
||||
dragController: TimelineDragController,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
onResize: (TimelineResizeDrop) -> Unit,
|
||||
) {
|
||||
WeekFrame(
|
||||
weekStart = state.weekStart,
|
||||
showWeekNumber = showWeekNumber,
|
||||
allDayHeight = allDayHeight,
|
||||
actions = actions,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
) {
|
||||
WeekPage(
|
||||
state = state,
|
||||
headerBias = headerBias,
|
||||
allDayHeight = allDayHeight,
|
||||
actions = actions,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
active = true,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
onResize = onResize,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -944,8 +984,14 @@ private fun WeekPage(
|
||||
state: WeekUiState.Success,
|
||||
headerBias: Float,
|
||||
allDayHeight: Dp,
|
||||
actions: TimelineActions,
|
||||
scrollState: ScrollState,
|
||||
dragController: TimelineDragController,
|
||||
active: Boolean,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
onResize: (TimelineResizeDrop) -> Unit,
|
||||
) {
|
||||
val geometry = remember(state.weekStart, state.days.size) {
|
||||
ColumnGeometry.of(DaySpan(state.weekStart, state.days.size))
|
||||
@@ -958,8 +1004,14 @@ private fun WeekPage(
|
||||
bars = bars,
|
||||
headerBias = headerBias,
|
||||
allDayHeight = allDayHeight,
|
||||
actions = actions,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
active = active,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
onResize = onResize,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -976,8 +1028,14 @@ private fun TimelinePageContent(
|
||||
headerBias: Float,
|
||||
allDayHeight: Dp,
|
||||
animatedAllDayHeight: (() -> Dp)? = null,
|
||||
actions: TimelineActions,
|
||||
scrollState: ScrollState,
|
||||
dragController: TimelineDragController,
|
||||
active: Boolean,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
onResize: (TimelineResizeDrop) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
@@ -985,14 +1043,14 @@ private fun TimelinePageContent(
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
) {
|
||||
WeekDayHeader(geometry = geometry, today = today, bias = headerBias, onOpenDay = actions.onOpenDay)
|
||||
WeekDayHeader(geometry = geometry, today = today, bias = headerBias, onOpenDay = onOpenDay)
|
||||
// This strip is painted on `surface`, so an outlined chip has to
|
||||
// fill with that and not with the columns' container (#230).
|
||||
CompositionLocalProvider(
|
||||
LocalChipGround provides MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
AllDayStrip(geometry = geometry, bars = bars, height = allDayHeight,
|
||||
animatedHeight = animatedAllDayHeight, onEventClick = actions.onEventClick)
|
||||
animatedHeight = animatedAllDayHeight, onEventClick = onEventClick)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(TIMELINE_TOP_GAP))
|
||||
@@ -1002,8 +1060,13 @@ private fun TimelinePageContent(
|
||||
timedFor = timedFor,
|
||||
allDayHeight = allDayHeight,
|
||||
animatedAllDayHeight = animatedAllDayHeight,
|
||||
actions = actions,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
active = active,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
onResize = onResize,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1227,11 +1290,14 @@ private fun Timeline(
|
||||
timedFor: (LocalDate) -> List<TimedBlock>,
|
||||
allDayHeight: Dp,
|
||||
animatedAllDayHeight: (() -> Dp)? = null,
|
||||
actions: TimelineActions,
|
||||
scrollState: ScrollState,
|
||||
dragController: TimelineDragController,
|
||||
active: Boolean,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
onResize: (TimelineResizeDrop) -> Unit,
|
||||
) {
|
||||
val scrollState = actions.scrollState
|
||||
val dragController = actions.dragController
|
||||
val fullViewportHeight = remember(geometry) { intArrayOf(-1) }
|
||||
val dark = LocalFloretDarkTheme.current
|
||||
val zoom = LocalTimelineZoom.current
|
||||
@@ -1324,7 +1390,12 @@ private fun Timeline(
|
||||
columnWidth = columnWidth,
|
||||
visibleTop = visibleTop,
|
||||
viewportHeight = visibleViewport,
|
||||
actions = actions,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
onResize = onResize,
|
||||
scrollState = scrollState,
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
)
|
||||
}
|
||||
@@ -1342,11 +1413,14 @@ private fun DayColumnCard(
|
||||
columnWidth: Dp,
|
||||
visibleTop: () -> Float,
|
||||
viewportHeight: () -> Float,
|
||||
actions: TimelineActions,
|
||||
dragController: TimelineDragController,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
onResize: (TimelineResizeDrop) -> Unit,
|
||||
scrollState: ScrollState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scrollState = actions.scrollState
|
||||
val dragController = actions.dragController
|
||||
val density = LocalDensity.current
|
||||
val hourPx = with(density) { hourHeight.toPx() }
|
||||
val morphing = LocalViewMorph.current != null
|
||||
@@ -1364,13 +1438,13 @@ private fun DayColumnCard(
|
||||
}
|
||||
// The tap detector outlives the composition that installed it — a pinch or a
|
||||
// Settings change moves the hour height without restarting it — so it reads
|
||||
// the height through a state handle instead of capturing it, or taps land on
|
||||
// the scale the column had before the zoom (#148). The actions read their
|
||||
// callbacks' latest values themselves.
|
||||
// the height and the callback through state handles instead of capturing
|
||||
// them, or taps land on the scale the column had before the zoom (#148).
|
||||
val currentHourPx = rememberUpdatedState(hourPx)
|
||||
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
|
||||
val edges = rememberOutOfViewEdges(blocks, scrollState, hourPx)
|
||||
val currentEdges = rememberUpdatedState(edges)
|
||||
val draftSurface = rememberDraftSurface(date, edges, hourPx, dragController, actions.onDrop)
|
||||
val draftSurface = rememberDraftSurface(date, edges, hourPx, dragController, onDrop)
|
||||
Card(
|
||||
// Plain rectangular columns — the soft corners come from the outer
|
||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||
@@ -1404,7 +1478,7 @@ private fun DayColumnCard(
|
||||
return@detectTapGestures
|
||||
}
|
||||
if (currentEdges.value.tap(offset.y)) return@detectTapGestures
|
||||
actions.onCreateAt(
|
||||
currentOnCreateAt.value(
|
||||
date,
|
||||
tappedMinuteOfDay(offset.y, currentHourPx.value),
|
||||
)
|
||||
@@ -1464,9 +1538,9 @@ private fun DayColumnCard(
|
||||
cuts = cuts,
|
||||
topInset = topInset,
|
||||
dragController = dragController,
|
||||
onClick = { actions.onEventClick(block.event) },
|
||||
onDrop = actions.onDrop,
|
||||
onResize = actions.onResize,
|
||||
onClick = { onEventClick(block.event) },
|
||||
onDrop = onDrop,
|
||||
onResize = onResize,
|
||||
modifier = Modifier
|
||||
.laneSlot(place)
|
||||
.offset(y = place.y + topInset)
|
||||
|
||||
@@ -10,7 +10,6 @@ import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.data.time.TodaySource
|
||||
import de.jeanlucmakiola.calendula.data.time.ZoneSource
|
||||
import de.jeanlucmakiola.calendula.data.time.currentDate
|
||||
import de.jeanlucmakiola.calendula.data.time.stateFollowingDays
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.displayPriority
|
||||
@@ -33,6 +32,7 @@ import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
@@ -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,12 +121,19 @@ 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)
|
||||
|
||||
private var lastToday = todayDate
|
||||
|
||||
/** Today, kept current across midnight so a page left open doesn't mark yesterday (#373). */
|
||||
private val today: StateFlow<LocalDate> =
|
||||
todaySource.today.stateFollowingDays(viewModelScope, todayDate, ::followToday)
|
||||
private val today: StateFlow<LocalDate> = todaySource.today
|
||||
.onEach { date ->
|
||||
val old = lastToday
|
||||
lastToday = date
|
||||
if (old != date) followToday(old, date)
|
||||
}
|
||||
.stateWhileSubscribed(viewModelScope, todayDate)
|
||||
|
||||
private val position: StateFlow<PagerPosition> =
|
||||
combine(_anchor, rule) { anchor, rule -> positionOf(anchor, rule) }
|
||||
@@ -233,11 +240,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 +285,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 },
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -52,16 +52,14 @@ internal fun WeekViewPreview(
|
||||
state = state,
|
||||
showWeekNumber = dayCount == 7,
|
||||
headerBias = if (dayCount == 1) TimelineKind.Day.headerBias else 0f,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = state.allDayStripHeight(),
|
||||
actions = rememberTimelineActions(
|
||||
scrollState = scrollState,
|
||||
dragController = rememberTimelineDragController(),
|
||||
onEventClick = {},
|
||||
onOpenDay = {},
|
||||
onCreateAt = { _, _ -> },
|
||||
onDrop = {},
|
||||
onResize = {},
|
||||
),
|
||||
dragController = rememberTimelineDragController(),
|
||||
onEventClick = {},
|
||||
onOpenDay = {},
|
||||
onCreateAt = { _, _ -> },
|
||||
onDrop = {},
|
||||
onResize = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,88 +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.glance.GlanceId
|
||||
import androidx.glance.appwidget.state.getAppWidgetState
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetThemeMode
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.floret.prefs.toEnum
|
||||
|
||||
/** A widget's own light/dark choice (#292); [FOLLOW_APP] tracks the app's theme setting. */
|
||||
enum class WidgetTheme { FOLLOW_APP, LIGHT, DARK }
|
||||
|
||||
/** How a widget looks, set per placed widget in its config screen (#292). */
|
||||
data class WidgetAppearance(
|
||||
val background: WidgetBackground = WidgetBackground.TINTED,
|
||||
val theme: WidgetTheme = WidgetTheme.FOLLOW_APP,
|
||||
)
|
||||
|
||||
/** What the agenda widget lists, per placed widget. */
|
||||
data class AgendaWidgetOptions(
|
||||
val range: AgendaRange,
|
||||
val textSize: WidgetSize,
|
||||
val showToday: Boolean,
|
||||
val pastDisplay: PastEventDisplay,
|
||||
)
|
||||
|
||||
internal fun WidgetTheme.resolve(app: ThemeMode): WidgetThemeMode = when (this) {
|
||||
WidgetTheme.LIGHT -> WidgetThemeMode.LIGHT
|
||||
WidgetTheme.DARK -> WidgetThemeMode.DARK
|
||||
WidgetTheme.FOLLOW_APP -> when (app) {
|
||||
ThemeMode.SYSTEM -> WidgetThemeMode.SYSTEM
|
||||
ThemeMode.LIGHT -> WidgetThemeMode.LIGHT
|
||||
ThemeMode.DARK, ThemeMode.BLACK -> WidgetThemeMode.DARK
|
||||
}
|
||||
}
|
||||
|
||||
private val BackgroundKey = stringPreferencesKey("cfg_background")
|
||||
private val ThemeKey = stringPreferencesKey("cfg_theme")
|
||||
private val AgendaRangeKey = stringPreferencesKey("cfg_agenda_range")
|
||||
private val TextSizeKey = stringPreferencesKey("cfg_text_size")
|
||||
private val ShowTodayKey = booleanPreferencesKey("cfg_show_today")
|
||||
private val PastDisplayKey = stringPreferencesKey("cfg_past_display")
|
||||
|
||||
internal val Preferences.widgetAppearance: WidgetAppearance
|
||||
get() = WidgetAppearance(
|
||||
background = this[BackgroundKey].toEnum(WidgetBackground.TINTED),
|
||||
theme = this[ThemeKey].toEnum(WidgetTheme.FOLLOW_APP),
|
||||
)
|
||||
|
||||
/**
|
||||
* This widget's agenda options. A widget placed before per-widget options
|
||||
* existed has none stored, and keeps following the app-wide values in
|
||||
* [defaults] until it is configured.
|
||||
*/
|
||||
internal fun Preferences.agendaWidgetOptions(defaults: AgendaWidgetOptions) = AgendaWidgetOptions(
|
||||
range = parseAgendaRange(this[AgendaRangeKey], defaults.range),
|
||||
textSize = this[TextSizeKey].toEnum(defaults.textSize),
|
||||
showToday = this[ShowTodayKey] ?: defaults.showToday,
|
||||
pastDisplay = this[PastDisplayKey].toEnum(defaults.pastDisplay),
|
||||
)
|
||||
|
||||
/** Whether this widget has agenda options of its own, rather than following the app's. */
|
||||
internal val Preferences.hasAgendaOptions: Boolean get() = this[AgendaRangeKey] != null
|
||||
|
||||
internal fun MutablePreferences.putAppearance(appearance: WidgetAppearance) {
|
||||
this[BackgroundKey] = appearance.background.name
|
||||
this[ThemeKey] = appearance.theme.name
|
||||
}
|
||||
|
||||
internal fun MutablePreferences.putAgendaOptions(options: AgendaWidgetOptions) {
|
||||
this[AgendaRangeKey] = options.range.storageValue()
|
||||
this[TextSizeKey] = options.textSize.name
|
||||
this[ShowTodayKey] = options.showToday
|
||||
this[PastDisplayKey] = options.pastDisplay.name
|
||||
}
|
||||
|
||||
internal suspend fun Context.widgetState(id: GlanceId): Preferences =
|
||||
getAppWidgetState(this, PreferencesGlanceStateDefinition, id)
|
||||
@@ -4,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,54 +34,7 @@ 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()
|
||||
|
||||
@@ -109,72 +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 options = widgetState(id).agendaWidgetOptions(settings.agendaDefaults)
|
||||
return Themed(loadAgendaWidgetData(settings, options), 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,
|
||||
): 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()
|
||||
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(),
|
||||
)
|
||||
}
|
||||
@@ -183,50 +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).also { MonthSourceCache.put(id, centerIndex, stamp, it) }
|
||||
|
||||
/** Read backing the month widget's grid for the window around [centerIndex], with the app theme. */
|
||||
internal suspend fun Context.loadThemedMonthSource(centerIndex: Int): Themed<MonthWidgetSource> {
|
||||
val settings = widgetEntryPoint().settingsPrefs().widgetSettings.first()
|
||||
return Themed(queryMonthSource(centerIndex, settings), settings.themeMode)
|
||||
}
|
||||
|
||||
private suspend fun Context.queryMonthSource(centerIndex: Int, settings: WidgetSettings): MonthWidgetSource {
|
||||
/** 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)
|
||||
@@ -235,11 +184,11 @@ private suspend fun Context.queryMonthSource(centerIndex: Int, settings: WidgetS
|
||||
.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(),
|
||||
today = anchor,
|
||||
weekStart = weekStart,
|
||||
instances = instances,
|
||||
months = months,
|
||||
)
|
||||
).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,137 +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.calendula.widget.agenda.AgendaWidget
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
|
||||
import de.jeanlucmakiola.floret.glance.WidgetRefresher
|
||||
import de.jeanlucmakiola.floret.glance.WidgetRollover
|
||||
import de.jeanlucmakiola.floret.glance.updatePlaced
|
||||
import de.jeanlucmakiola.floret.glance.updateWithRefresh
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Every widget receiver; the set [WidgetRollover] checks for placed instances. */
|
||||
internal val WIDGET_RECEIVER_CLASSES: List<Class<*>> = listOf(
|
||||
MonthWidgetReceiver::class.java,
|
||||
AgendaWidgetReceiver::class.java,
|
||||
)
|
||||
|
||||
/** The app's own midnight wake-up (#228). */
|
||||
internal const val ACTION_WIDGET_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER"
|
||||
|
||||
/** Fixed: there is only ever one rollover alarm, and re-arming must replace it. */
|
||||
private const val ROLLOVER_REQUEST_CODE = 0x0DA1
|
||||
|
||||
internal fun widgetRolloverPendingIntent(context: Context): PendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
ROLLOVER_REQUEST_CODE,
|
||||
Intent(context, WidgetUpdateReceiver::class.java).setAction(ACTION_WIDGET_ROLLOVER),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
/** Arm the next midnight rollover, or cancel it when no widget is placed. Idempotent. */
|
||||
internal fun syncWidgetRollover(context: Context) =
|
||||
WidgetRollover.sync(context, WIDGET_RECEIVER_CLASSES, ::widgetRolloverPendingIntent)
|
||||
|
||||
/**
|
||||
* Reloads and redraws every placed widget, a running session included: the
|
||||
* refresh stamp moves, and both widgets key their data load on it (#420).
|
||||
*/
|
||||
internal suspend fun redrawWidgets(context: Context) {
|
||||
redrawAgendaWidgets(context)
|
||||
redrawMonthWidgets(context)
|
||||
}
|
||||
|
||||
private suspend fun redrawAgendaWidgets(context: Context) =
|
||||
AgendaWidget().updatePlaced(context, AgendaWidgetReceiver::class.java, bumpRefreshStamp = true)
|
||||
|
||||
/** Redraws only the agenda widgets with no options of their own, which still follow the app's. */
|
||||
private suspend fun redrawUnconfiguredAgendaWidgets(context: Context) {
|
||||
val appContext = context.applicationContext
|
||||
val ids = AppWidgetManager.getInstance(appContext)
|
||||
?.getAppWidgetIds(ComponentName(appContext, AgendaWidgetReceiver::class.java)) ?: return
|
||||
val glanceManager = GlanceAppWidgetManager(appContext)
|
||||
ids.forEach { appWidgetId ->
|
||||
val glanceId = glanceManager.getGlanceIdBy(appWidgetId)
|
||||
if (!appContext.widgetState(glanceId).hasAgendaOptions) AgendaWidget().updateWithRefresh(appContext, glanceId)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun redrawMonthWidgets(context: Context) =
|
||||
MonthWidget().updatePlaced(context, MonthWidgetReceiver::class.java, bumpRefreshStamp = true)
|
||||
|
||||
/** When the app last saw a calendar change of its own, for skipping the `PROVIDER_CHANGED` that echoes it. */
|
||||
private val lastLocalChange = AtomicLong(0L)
|
||||
|
||||
private const val PROVIDER_ECHO_WINDOW_MILLIS = 5_000L
|
||||
|
||||
internal fun isProviderEcho(): Boolean {
|
||||
val last = lastLocalChange.get()
|
||||
return last != 0L && SystemClock.elapsedRealtime() - last < PROVIDER_ECHO_WINDOW_MILLIS
|
||||
}
|
||||
|
||||
/**
|
||||
* Redraws the widgets on the app's own calendar writes, visibility changes and
|
||||
* the settings they draw with, so those don't wait for `PROVIDER_CHANGED`,
|
||||
* which arrives seconds late or not at all (#420, #229). Changes from outside
|
||||
* the app are [WidgetUpdateReceiver]'s. The app's agenda options only redraw
|
||||
* agenda widgets that have none of their own (#292). Started once from
|
||||
* `CalendulaApp`.
|
||||
*/
|
||||
@Singleton
|
||||
class WidgetRefreshCoordinator @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val repository: CalendarRepository,
|
||||
private val prefs: SettingsPrefs,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
) {
|
||||
private val started = AtomicBoolean(false)
|
||||
|
||||
/** Subscribes before returning, so a change made right after is never taken for the starting value. */
|
||||
fun start() {
|
||||
if (!started.compareAndSet(false, true)) return
|
||||
val agenda = WidgetRefresher(scope) { redrawAgendaWidgets(context) }
|
||||
val unconfiguredAgenda = WidgetRefresher(scope) { redrawUnconfiguredAgendaWidgets(context) }
|
||||
val month = WidgetRefresher(scope) { redrawMonthWidgets(context) }
|
||||
watch(repository.localChanges) {
|
||||
lastLocalChange.set(SystemClock.elapsedRealtime())
|
||||
agenda.request()
|
||||
month.request()
|
||||
}
|
||||
watch(prefs.widgetSettings.map { it.shared }.distinctUntilChanged().drop(1)) {
|
||||
agenda.request()
|
||||
month.request()
|
||||
}
|
||||
watch(prefs.widgetSettings.map { it.agendaDefaults }.distinctUntilChanged().drop(1)) {
|
||||
unconfiguredAgenda.request()
|
||||
}
|
||||
}
|
||||
|
||||
private fun watch(changes: Flow<*>, onChange: () -> Unit) {
|
||||
scope.launch(Dispatchers.IO, start = CoroutineStart.UNDISPATCHED) {
|
||||
changes.collect { onChange() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -3,22 +3,38 @@ package de.jeanlucmakiola.calendula.widget.agenda
|
||||
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
|
||||
@@ -31,64 +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.loadAgendaWidget
|
||||
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.glance.LocalWidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import de.jeanlucmakiola.floret.glance.WidgetGroupedRow
|
||||
import de.jeanlucmakiola.floret.glance.themedColor
|
||||
import de.jeanlucmakiola.floret.glance.wallpaperBacking
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.glance.currentState
|
||||
import de.jeanlucmakiola.floret.glance.WidgetData
|
||||
import de.jeanlucmakiola.floret.glance.currentRefreshStamp
|
||||
import de.jeanlucmakiola.floret.glance.refreshStamp
|
||||
import de.jeanlucmakiola.calendula.widget.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() {
|
||||
@@ -102,41 +132,31 @@ class AgendaWidget : GlanceAppWidget() {
|
||||
override val sizeMode = SizeMode.Single
|
||||
|
||||
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
|
||||
@@ -146,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,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 ->
|
||||
@@ -200,230 +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 = agendaCardColor()
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Cards sit one tone off the root fill, and stay opaque over a wallpaper (#292). */
|
||||
@Composable
|
||||
private fun agendaCardColor(): ColorProvider = when (LocalWidgetBackground.current) {
|
||||
WidgetBackground.SOLID -> GlanceTheme.colors.surfaceVariant
|
||||
WidgetBackground.TINTED, WidgetBackground.TRANSPARENT -> GlanceTheme.colors.widgetBackground
|
||||
}
|
||||
|
||||
/** 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(
|
||||
@@ -437,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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,7 +480,6 @@ 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -487,16 +501,6 @@ 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,
|
||||
|
||||
+6
-16
@@ -1,13 +1,10 @@
|
||||
package de.jeanlucmakiola.calendula.widget.agenda
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.syncWidgetRollover
|
||||
import de.jeanlucmakiola.floret.glance.reloadOnUpdate
|
||||
import de.jeanlucmakiola.floret.glance.restoreState
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
|
||||
|
||||
/**
|
||||
* Host-facing receiver for the agenda widget. Declared in the manifest with the
|
||||
@@ -20,35 +17,28 @@ class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
/** First agenda widget placed — start rolling "today" over at midnight (#228). */
|
||||
override fun onEnabled(context: Context) {
|
||||
super.onEnabled(context)
|
||||
syncWidgetRollover(context)
|
||||
WidgetRolloverScheduler.sync(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Last agenda widget removed. [syncWidgetRollover] cancels only if
|
||||
* Last agenda widget removed. [WidgetRolloverScheduler.sync] cancels only if
|
||||
* no month widget is left either.
|
||||
*/
|
||||
override fun onDisabled(context: Context) {
|
||||
super.onDisabled(context)
|
||||
syncWidgetRollover(context)
|
||||
}
|
||||
|
||||
/** A backup restore or device transfer renumbered the widgets: keep each one's settings (#292). */
|
||||
override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) {
|
||||
restoreState(context, oldWidgetIds, newWidgetIds)
|
||||
WidgetRolloverScheduler.sync(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-heal on the system's own `updatePeriodMillis` wake-up — see
|
||||
* [de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver.onUpdate].
|
||||
*/
|
||||
@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)
|
||||
super.onUpdate(context, appWidgetManager, appWidgetIds)
|
||||
WidgetRolloverScheduler.sync(context)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +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.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,130 +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,
|
||||
onAgendaOptions = viewModel::setAgendaOptions,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun result(appWidgetId: Int) = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
|
||||
|
||||
companion object {
|
||||
/** Opens placed widget [appWidgetId]'s config screen from inside the app. */
|
||||
fun intent(context: Context, appWidgetId: Int): Intent =
|
||||
Intent(context, WidgetConfigActivity::class.java)
|
||||
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
|
||||
}
|
||||
}
|
||||
@@ -1,328 +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.WidgetSize
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetTheme
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetContent
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.metricsFor
|
||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetContent
|
||||
import de.jeanlucmakiola.floret.components.GroupedChoiceRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||
import de.jeanlucmakiola.floret.components.GroupedSwitchRow
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.glance.GlanceWidgetPreview
|
||||
import de.jeanlucmakiola.floret.glance.WallpaperWindow
|
||||
import de.jeanlucmakiola.floret.glance.WidgetBackground
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val PreviewMaxHeight = 280.dp
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun WidgetConfigScreen(
|
||||
state: WidgetConfigUiState,
|
||||
onClose: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onAppearance: (WidgetAppearance) -> Unit,
|
||||
onAgendaOptions: (AgendaWidgetOptions) -> Unit,
|
||||
) {
|
||||
val ready = state as? WidgetConfigUiState.Ready
|
||||
val surface = MaterialTheme.colorScheme.surface
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = ready?.let { stringResource(it.kind.label) }.orEmpty(),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.event_edit_close))
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
Button(
|
||||
onClick = onSave,
|
||||
enabled = ready != null && !ready.saving,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.dialog_save))
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = surface),
|
||||
)
|
||||
when (state) {
|
||||
WidgetConfigUiState.Loading, WidgetConfigUiState.Missing -> Box(
|
||||
modifier = Modifier.fillMaxSize().background(surface),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
WidgetConfigUiState.Failed -> Column(
|
||||
modifier = Modifier.fillMaxSize().background(surface).padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.widget_config_load_failed),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
FilledTonalButton(onClick = onRetry) { Text(stringResource(R.string.state_retry)) }
|
||||
}
|
||||
is WidgetConfigUiState.Ready -> {
|
||||
WallpaperWindow(color = surface) {
|
||||
val maxSize = DpSize(maxWidth, PreviewMaxHeight)
|
||||
when (val preview = state.preview) {
|
||||
null -> Box(
|
||||
modifier = Modifier.padding(vertical = 16.dp).height(maxSize.height / 2),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (state.previewFailed) PreviewFailed() else CircularProgressIndicator()
|
||||
}
|
||||
else -> GlanceWidgetPreview(
|
||||
size = state.placedSize,
|
||||
maxSize = maxSize,
|
||||
preview,
|
||||
state.appearance,
|
||||
state.appTheme,
|
||||
modifier = Modifier.padding(vertical = 16.dp),
|
||||
placeholder = { CircularProgressIndicator() },
|
||||
failure = { PreviewFailed() },
|
||||
) {
|
||||
when (preview) {
|
||||
is WidgetPreviewData.Agenda ->
|
||||
AgendaWidgetContent(preview.data, state.appearance, state.appTheme, preview = true)
|
||||
is WidgetPreviewData.Month ->
|
||||
MonthWidgetContent(preview.source, preview.index, state.appearance, state.appTheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ConfigOptions(
|
||||
state = state,
|
||||
onAppearance = onAppearance,
|
||||
onAgendaOptions = onAgendaOptions,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.background(surface),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PreviewFailed() {
|
||||
Text(
|
||||
text = stringResource(R.string.widget_config_preview_failed),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.large)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
internal val WidgetKind.label: Int
|
||||
get() = when (this) {
|
||||
WidgetKind.AGENDA -> R.string.widget_agenda_label
|
||||
WidgetKind.MONTH -> R.string.widget_month_label
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConfigOptions(
|
||||
state: WidgetConfigUiState.Ready,
|
||||
onAppearance: (WidgetAppearance) -> Unit,
|
||||
onAgendaOptions: (AgendaWidgetOptions) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.navigationBarsPadding()
|
||||
.padding(bottom = 16.dp),
|
||||
) {
|
||||
GroupedSectionHeader(stringResource(R.string.settings_section_appearance))
|
||||
GroupedChoiceRow(
|
||||
title = stringResource(R.string.widget_config_background),
|
||||
options = WidgetBackground.entries,
|
||||
selected = state.appearance.background,
|
||||
label = { backgroundLabel(it) },
|
||||
onSelect = { onAppearance(state.appearance.copy(background = it)) },
|
||||
position = Position.Top,
|
||||
)
|
||||
GroupedChoiceRow(
|
||||
title = stringResource(R.string.settings_theme),
|
||||
options = WidgetTheme.entries,
|
||||
selected = state.appearance.theme,
|
||||
label = { themeLabel(it) },
|
||||
onSelect = { onAppearance(state.appearance.copy(theme = it)) },
|
||||
position = Position.Bottom,
|
||||
)
|
||||
state.agenda?.let { options ->
|
||||
AgendaOptions(options = options, state = state, onChange = onAgendaOptions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaOptions(
|
||||
options: AgendaWidgetOptions,
|
||||
state: WidgetConfigUiState.Ready,
|
||||
onChange: (AgendaWidgetOptions) -> Unit,
|
||||
) {
|
||||
var showRange by rememberSaveable { mutableStateOf(false) }
|
||||
var showSize by rememberSaveable { mutableStateOf(false) }
|
||||
var showPast by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
GroupedSectionHeader(stringResource(R.string.widget_config_content))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.widget_config_range),
|
||||
summary = agendaRangeLabel(options.range),
|
||||
position = Position.Top,
|
||||
onClick = { showRange = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.widget_config_text_size),
|
||||
summary = widgetSizeLabel(options.textSize),
|
||||
position = Position.Middle,
|
||||
onClick = { showSize = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_past_events),
|
||||
summary = stringResource(pastEventDisplayLabelRes(options.pastDisplay)),
|
||||
position = Position.Middle,
|
||||
onClick = { showPast = true },
|
||||
)
|
||||
GroupedSwitchRow(
|
||||
title = stringResource(R.string.settings_agenda_show_today),
|
||||
checked = options.showToday,
|
||||
onCheckedChange = { onChange(options.copy(showToday = it)) },
|
||||
position = Position.Bottom,
|
||||
)
|
||||
|
||||
if (showRange) {
|
||||
AgendaRangePicker(
|
||||
title = stringResource(R.string.widget_config_range),
|
||||
description = stringResource(R.string.settings_agenda_widget_range_hint),
|
||||
selected = options.range,
|
||||
weekStart = state.weekStart,
|
||||
onSelect = { onChange(options.copy(range = it)) },
|
||||
onDismiss = { showRange = false },
|
||||
)
|
||||
}
|
||||
if (showSize) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.widget_config_text_size),
|
||||
header = { GroupedHint(stringResource(R.string.settings_widget_size_hint), verticalPadding = 8.dp) },
|
||||
predictiveBack = true,
|
||||
options = WidgetSize.entries,
|
||||
selected = options.textSize,
|
||||
label = { widgetSizeLabel(it) },
|
||||
summary = { widgetSizeSummary(it) },
|
||||
onSelect = { onChange(options.copy(textSize = it)) },
|
||||
onDismiss = { showSize = false },
|
||||
)
|
||||
}
|
||||
if (showPast) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_past_events),
|
||||
header = { GroupedHint(stringResource(R.string.settings_past_events_hint), verticalPadding = 8.dp) },
|
||||
predictiveBack = true,
|
||||
options = PastEventDisplay.entries,
|
||||
selected = options.pastDisplay,
|
||||
label = { stringResource(pastEventDisplayLabelRes(it)) },
|
||||
onSelect = { onChange(options.copy(pastDisplay = it)) },
|
||||
onDismiss = { showPast = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun backgroundLabel(background: WidgetBackground): String = stringResource(
|
||||
when (background) {
|
||||
WidgetBackground.SOLID -> R.string.widget_config_background_solid
|
||||
WidgetBackground.TINTED -> R.string.widget_config_background_tinted
|
||||
WidgetBackground.TRANSPARENT -> R.string.widget_config_background_transparent
|
||||
},
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun themeLabel(theme: WidgetTheme): String = stringResource(
|
||||
when (theme) {
|
||||
WidgetTheme.FOLLOW_APP -> R.string.widget_config_theme_follow_app
|
||||
WidgetTheme.LIGHT -> R.string.settings_theme_light
|
||||
WidgetTheme.DARK -> R.string.settings_theme_dark
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* What a size step does, as a percentage of the smallest one's event text.
|
||||
* Derived from the widget's own [metricsFor] table rather than hardcoded.
|
||||
*/
|
||||
@Composable
|
||||
private fun widgetSizeSummary(size: WidgetSize): String {
|
||||
val base = metricsFor(WidgetSize.SMALL).eventTitle.value
|
||||
val percent = (metricsFor(size).eventTitle.value / base * 100f).roundToInt()
|
||||
return stringResource(R.string.settings_widget_size_summary, percent)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun widgetSizeLabel(size: WidgetSize): String = stringResource(
|
||||
when (size) {
|
||||
WidgetSize.SMALL -> R.string.settings_widget_size_small
|
||||
WidgetSize.MEDIUM -> R.string.settings_widget_size_medium
|
||||
WidgetSize.LARGE -> R.string.settings_widget_size_large
|
||||
WidgetSize.EXTRA_LARGE -> R.string.settings_widget_size_extra_large
|
||||
},
|
||||
)
|
||||
@@ -1,242 +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.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetOptions
|
||||
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.loadThemedMonthSource
|
||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.monthIndexOf
|
||||
import de.jeanlucmakiola.calendula.widget.monthWindowCenter
|
||||
import de.jeanlucmakiola.calendula.widget.agendaWidgetOptions
|
||||
import de.jeanlucmakiola.calendula.widget.putAgendaOptions
|
||||
import de.jeanlucmakiola.calendula.widget.putAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||
import de.jeanlucmakiola.calendula.widget.today
|
||||
import de.jeanlucmakiola.calendula.widget.widgetAppearance
|
||||
import de.jeanlucmakiola.calendula.widget.widgetSettings
|
||||
import de.jeanlucmakiola.calendula.widget.widgetState
|
||||
import de.jeanlucmakiola.floret.glance.updateWithRefresh
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import java.util.Locale
|
||||
|
||||
/** The widgets a config screen can edit, told apart by their receiver. */
|
||||
enum class WidgetKind(val receiver: Class<*>) {
|
||||
AGENDA(AgendaWidgetReceiver::class.java),
|
||||
MONTH(MonthWidgetReceiver::class.java),
|
||||
;
|
||||
|
||||
fun widget(): GlanceAppWidget = when (this) {
|
||||
AGENDA -> AgendaWidget()
|
||||
MONTH -> MonthWidget()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun of(context: Context, appWidgetId: Int): WidgetKind? {
|
||||
val provider = AppWidgetManager.getInstance(context)?.getAppWidgetInfo(appWidgetId)?.provider ?: return null
|
||||
return entries.firstOrNull { it.receiver.name == provider.className }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What the preview draws, loaded with the draft's options. */
|
||||
sealed interface WidgetPreviewData {
|
||||
data class Agenda(val data: AgendaWidgetData) : WidgetPreviewData
|
||||
data class Month(val source: MonthWidgetSource, val index: Int) : WidgetPreviewData
|
||||
}
|
||||
|
||||
sealed interface WidgetConfigUiState {
|
||||
data object Loading : WidgetConfigUiState
|
||||
|
||||
/** The widget is gone, or isn't one of ours; the screen closes. */
|
||||
data object Missing : WidgetConfigUiState
|
||||
|
||||
/** Its stored options couldn't be read. */
|
||||
data object Failed : WidgetConfigUiState
|
||||
|
||||
data class Ready(
|
||||
val kind: WidgetKind,
|
||||
val appearance: WidgetAppearance,
|
||||
/** Null for a widget with no content options. */
|
||||
val agenda: AgendaWidgetOptions?,
|
||||
val appTheme: ThemeMode,
|
||||
val weekStart: DayOfWeek,
|
||||
/** The widget's size on the home screen, which the preview is laid out at. */
|
||||
val placedSize: DpSize,
|
||||
/** Null until the first preview load lands. */
|
||||
val preview: WidgetPreviewData?,
|
||||
/** The first preview load failed, so there's nothing to show. */
|
||||
val previewFailed: Boolean = false,
|
||||
val saving: Boolean = false,
|
||||
) : WidgetConfigUiState
|
||||
}
|
||||
|
||||
/** One-time outcomes of a save, for the screen that's showing when they land. */
|
||||
enum class WidgetConfigEvent { SAVED, SAVE_FAILED }
|
||||
|
||||
/**
|
||||
* Backs [WidgetConfigActivity] (#292): the placed widget's stored options as an
|
||||
* editable draft, a preview loaded with that draft, and the save that writes it
|
||||
* into the widget's Glance state and redraws it.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class WidgetConfigViewModel @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val prefs: SettingsPrefs,
|
||||
savedState: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
val appWidgetId: Int = savedState[AppWidgetManager.EXTRA_APPWIDGET_ID] ?: AppWidgetManager.INVALID_APPWIDGET_ID
|
||||
|
||||
private val _state = MutableStateFlow<WidgetConfigUiState>(WidgetConfigUiState.Loading)
|
||||
val state: StateFlow<WidgetConfigUiState> = _state.asStateFlow()
|
||||
|
||||
private val _events = Channel<WidgetConfigEvent>(Channel.BUFFERED)
|
||||
val events: Flow<WidgetConfigEvent> = _events.receiveAsFlow()
|
||||
|
||||
private var previewJob: Job? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch { load() }
|
||||
}
|
||||
|
||||
fun retry() {
|
||||
if (_state.value != WidgetConfigUiState.Failed) return
|
||||
_state.value = WidgetConfigUiState.Loading
|
||||
viewModelScope.launch { load() }
|
||||
}
|
||||
|
||||
private suspend fun load() {
|
||||
val kind = WidgetKind.of(context, appWidgetId)
|
||||
if (kind == null) {
|
||||
_state.value = WidgetConfigUiState.Missing
|
||||
return
|
||||
}
|
||||
_state.value = try {
|
||||
val stored = context.widgetState(GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId))
|
||||
val settings = prefs.widgetSettings.first()
|
||||
WidgetConfigUiState.Ready(
|
||||
kind = kind,
|
||||
appearance = stored.widgetAppearance,
|
||||
agenda = if (kind == WidgetKind.AGENDA) stored.agendaWidgetOptions(settings.agendaDefaults) else null,
|
||||
appTheme = settings.themeMode,
|
||||
weekStart = settings.weekStart.resolveFirstDay(Locale.getDefault()),
|
||||
placedSize = placedSize(kind),
|
||||
preview = null,
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: IllegalArgumentException) {
|
||||
// getGlanceIdBy: the widget was removed meanwhile.
|
||||
WidgetConfigUiState.Missing
|
||||
} catch (_: Exception) {
|
||||
WidgetConfigUiState.Failed
|
||||
}
|
||||
reloadPreview()
|
||||
}
|
||||
|
||||
/** The size the launcher placed the widget at (portrait), or a default before it has reported one. */
|
||||
private fun placedSize(kind: WidgetKind): DpSize {
|
||||
val options = AppWidgetManager.getInstance(context)?.getAppWidgetOptions(appWidgetId)
|
||||
val width = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) ?: 0
|
||||
val height = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT) ?: 0
|
||||
if (width > 0 && height > 0) return DpSize(width.dp, height.dp)
|
||||
return when (kind) {
|
||||
WidgetKind.AGENDA -> DpSize(260.dp, 280.dp)
|
||||
WidgetKind.MONTH -> DpSize(320.dp, 340.dp)
|
||||
}
|
||||
}
|
||||
|
||||
fun setAppearance(appearance: WidgetAppearance) = edit { it.copy(appearance = appearance) }
|
||||
|
||||
fun setAgendaOptions(options: AgendaWidgetOptions) {
|
||||
edit { it.copy(agenda = options) }
|
||||
reloadPreview()
|
||||
}
|
||||
|
||||
private fun edit(change: (WidgetConfigUiState.Ready) -> WidgetConfigUiState.Ready) {
|
||||
_state.update { if (it is WidgetConfigUiState.Ready) change(it) else it }
|
||||
}
|
||||
|
||||
private fun reloadPreview() {
|
||||
val ready = _state.value as? WidgetConfigUiState.Ready ?: return
|
||||
previewJob?.cancel()
|
||||
previewJob = viewModelScope.launch {
|
||||
val preview = try {
|
||||
loadPreview(ready)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
// The options stay editable; the preview keeps what it had.
|
||||
edit { it.copy(previewFailed = it.preview == null) }
|
||||
return@launch
|
||||
}
|
||||
edit { it.copy(preview = preview, previewFailed = false) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadPreview(ready: WidgetConfigUiState.Ready): WidgetPreviewData = when (ready.kind) {
|
||||
WidgetKind.AGENDA -> {
|
||||
val settings = prefs.widgetSettings.first()
|
||||
WidgetPreviewData.Agenda(context.loadAgendaWidgetData(settings, requireNotNull(ready.agenda)))
|
||||
}
|
||||
WidgetKind.MONTH -> {
|
||||
val index = monthIndexOf(today(systemZone()))
|
||||
WidgetPreviewData.Month(context.loadThemedMonthSource(monthWindowCenter(index)).content, index)
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes the draft into the widget and redraws it, then reports how that went on [events]. */
|
||||
fun save() {
|
||||
val ready = _state.value as? WidgetConfigUiState.Ready ?: return
|
||||
if (ready.saving) return
|
||||
edit { it.copy(saving = true) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId)
|
||||
ready.kind.widget().updateWithRefresh(context, glanceId) { state ->
|
||||
state.putAppearance(ready.appearance)
|
||||
ready.agenda?.let { state.putAgendaOptions(it) }
|
||||
}
|
||||
_events.send(WidgetConfigEvent.SAVED)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: IllegalArgumentException) {
|
||||
_state.value = WidgetConfigUiState.Missing
|
||||
} catch (_: Exception) {
|
||||
edit { it.copy(saving = false) }
|
||||
_events.send(WidgetConfigEvent.SAVE_FAILED)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,43 +1,47 @@
|
||||
package de.jeanlucmakiola.calendula.widget.month
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
|
||||
import android.content.Context
|
||||
import android.content.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
|
||||
@@ -45,37 +49,28 @@ 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
|
||||
@@ -88,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 =
|
||||
@@ -108,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.
|
||||
*
|
||||
@@ -121,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() {
|
||||
|
||||
@@ -131,55 +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 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).
|
||||
*
|
||||
@@ -216,55 +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)
|
||||
// Paging past the loaded months reloads around the new one in a
|
||||
// live session, where provideGlance doesn't run again.
|
||||
val context = LocalContext.current
|
||||
var loaded by remember(source) { mutableStateOf<MonthWidgetSource.Ready>(source) }
|
||||
LaunchedEffect(index) {
|
||||
if (index !in loaded.months) {
|
||||
(context.loadMonthWidgetSource(index) as? MonthWidgetSource.Ready)
|
||||
?.let { loaded = it }
|
||||
}
|
||||
}
|
||||
// Column width from the live widget size, minus our H padding.
|
||||
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
|
||||
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
|
||||
val context = LocalContext.current
|
||||
val fontScale = context.resources.configuration.fontScale
|
||||
val weekHeight = (LocalSize.current.height - gridChromeHeight(fontScale)) / weeks.size
|
||||
val layouts = fitWeeks(weeks, lanesFitting(weekHeight))
|
||||
val mode = LocalWidgetThemeMode.current
|
||||
val colors = GridColors(
|
||||
onSurface = remoteColor(WidgetColorRole.ON_SURFACE),
|
||||
onSurfaceVariant = remoteColor(WidgetColorRole.ON_SURFACE_VARIANT),
|
||||
primary = remoteColor(WidgetColorRole.PRIMARY),
|
||||
onPrimary = remoteColor(WidgetColorRole.ON_PRIMARY),
|
||||
cell = remoteColor(monthCellRole()),
|
||||
)
|
||||
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, colW = colW)
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -273,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,
|
||||
onClick = GlanceModifier.clickable(
|
||||
actionRunCallback<ShiftMonthAction>(
|
||||
actionParametersOf(ShiftMonthAction.deltaKey to 1),
|
||||
),
|
||||
),
|
||||
)
|
||||
WidgetHeaderActionGap()
|
||||
WidgetHeaderAction(
|
||||
icon = ImageProvider(R.drawable.ic_widget_add),
|
||||
contentDescription = context.getString(R.string.widget_new_event),
|
||||
action = actionStartActivity(MainActivity.openCreateIntent(context, today)),
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeaderIcon(resId: Int, contentDescription: String, onClick: GlanceModifier) {
|
||||
Box(
|
||||
modifier = GlanceModifier.size(40.dp).then(onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Image(
|
||||
provider = ImageProvider(resId),
|
||||
contentDescription = contentDescription,
|
||||
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
|
||||
modifier = GlanceModifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekdayHeader(weekStart: DayOfWeek, colW: Dp) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth().padding(bottom = 1.dp)) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
weekdayNarrowNames(weekStart).forEach { name ->
|
||||
Box(modifier = GlanceModifier.width(colW).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> {
|
||||
@@ -356,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
|
||||
@@ -370,7 +605,6 @@ private fun PermissionMessage() {
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
modifier = GlanceModifier.wallpaperBacking().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
package de.jeanlucmakiola.calendula.widget.month
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import de.jeanlucmakiola.calendula.widget.syncWidgetRollover
|
||||
import de.jeanlucmakiola.floret.glance.reloadOnUpdate
|
||||
import de.jeanlucmakiola.floret.glance.restoreState
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
|
||||
|
||||
/**
|
||||
* Host-facing receiver for the month widget. Declared in the manifest with the
|
||||
@@ -19,21 +16,16 @@ class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
/** First month widget placed — start rolling "today" over at midnight (#228). */
|
||||
override fun onEnabled(context: Context) {
|
||||
super.onEnabled(context)
|
||||
syncWidgetRollover(context)
|
||||
WidgetRolloverScheduler.sync(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Last month widget removed. [syncWidgetRollover] cancels only if
|
||||
* Last month widget removed. [WidgetRolloverScheduler.sync] cancels only if
|
||||
* no agenda widget is left either.
|
||||
*/
|
||||
override fun onDisabled(context: Context) {
|
||||
super.onDisabled(context)
|
||||
syncWidgetRollover(context)
|
||||
}
|
||||
|
||||
/** A backup restore or device transfer renumbered the widgets: keep each one's settings (#292). */
|
||||
override fun onRestored(context: Context, oldWidgetIds: IntArray, newWidgetIds: IntArray) {
|
||||
restoreState(context, oldWidgetIds, newWidgetIds)
|
||||
WidgetRolloverScheduler.sync(context)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,14 +35,12 @@ class MonthWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
* freeze) is repaired here rather than on the next app open. Re-arming
|
||||
* closer to midnight also narrows the inexact delivery window.
|
||||
*/
|
||||
@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)
|
||||
super.onUpdate(context, appWidgetManager, appWidgetIds)
|
||||
WidgetRolloverScheduler.sync(context)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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" />
|
||||
@@ -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="3" />
|
||||
@@ -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="4" />
|
||||
@@ -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="5" />
|
||||
@@ -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="6" />
|
||||
@@ -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="7" />
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/month_grid"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical" />
|
||||
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/lane"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="15dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingBottom="1dp"
|
||||
android:weightSum="7" />
|
||||
@@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/more"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center_vertical"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:paddingStart="3dp"
|
||||
android:textSize="9sp" />
|
||||
@@ -1,360 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="horizontal">
|
||||
<FrameLayout
|
||||
android:id="@+id/strip_0"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:foreground="?android:attr/selectableItemBackground"
|
||||
android:padding="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_0"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_dim_0"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:id="@+id/strip_1"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:foreground="?android:attr/selectableItemBackground"
|
||||
android:padding="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_dim_1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:id="@+id/strip_2"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:foreground="?android:attr/selectableItemBackground"
|
||||
android:padding="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_2"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_dim_2"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:id="@+id/strip_3"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:foreground="?android:attr/selectableItemBackground"
|
||||
android:padding="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_3"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_dim_3"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:id="@+id/strip_4"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:foreground="?android:attr/selectableItemBackground"
|
||||
android:padding="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_4"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_dim_4"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:id="@+id/strip_5"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:foreground="?android:attr/selectableItemBackground"
|
||||
android:padding="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_5"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_dim_5"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:id="@+id/strip_6"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:foreground="?android:attr/selectableItemBackground"
|
||||
android:padding="1dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_6"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cell_dim_6"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="fitXY"
|
||||
android:src="@drawable/widget_month_cell_bg"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="18dp"
|
||||
android:orientation="horizontal">
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/today_bg_0"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_gravity="center"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/widget_month_today_bg"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/day_0"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:includeFontPadding="false"
|
||||
android:textSize="11sp" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/today_bg_1"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_gravity="center"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/widget_month_today_bg"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/day_1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:includeFontPadding="false"
|
||||
android:textSize="11sp" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/today_bg_2"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_gravity="center"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/widget_month_today_bg"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/day_2"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:includeFontPadding="false"
|
||||
android:textSize="11sp" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/today_bg_3"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_gravity="center"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/widget_month_today_bg"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/day_3"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:includeFontPadding="false"
|
||||
android:textSize="11sp" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/today_bg_4"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_gravity="center"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/widget_month_today_bg"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/day_4"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:includeFontPadding="false"
|
||||
android:textSize="11sp" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/today_bg_5"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_gravity="center"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/widget_month_today_bg"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/day_5"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:includeFontPadding="false"
|
||||
android:textSize="11sp" />
|
||||
</FrameLayout>
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/today_bg_6"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_gravity="center"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/widget_month_today_bg"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/day_6"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:includeFontPadding="false"
|
||||
android:textSize="11sp" />
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/week_lanes"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="2dp" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
@@ -302,7 +302,7 @@
|
||||
<string name="agenda_range_month">٣٠ يومًا القادمة</string>
|
||||
<string name="settings_month_view_style">نمط عرض الشهر</string>
|
||||
<string name="month_style_paged">صفحات</string>
|
||||
<string name="month_style_paged_summary">الشهر الواحد يملأ الشاشة. اسحب لليسار أو لليمين لتغيير الشهر، أو اسحب للأعلى لعرض جدول الشهر.</string>
|
||||
<string name="month_style_paged_summary">الشهر الواحد يملأ الشاشة. اسحب لليسار أو لليمين لتغيير الشهر.</string>
|
||||
<string name="month_style_split">تقسيم</string>
|
||||
<string name="settings_color_unsupported">السماح بالألوان في التقويمات غير المدعومة</string>
|
||||
<string name="settings_color_unsupported_hint">بعض التقويمات (مثل البعض من CalDAV) لا تنشر أي مجموعة ألوان؛ لون حدث مخصص قد يزال أو يستبدل في مزامنته التالية. هذا قيد على تلك التقويمات، وليس شيئًا يمكن لـ Calendula إصلاحه.</string>
|
||||
@@ -450,7 +450,7 @@
|
||||
<string name="settings_special_dates_grant">منح الوصول</string>
|
||||
<string name="calendars_backup_action">تصدير كملف .ics</string>
|
||||
<string name="calendars_export_title">تصدير التقويمات</string>
|
||||
<string name="calendars_export_hint">اختر التقويمات التي تريد تضمينها في الملف .ics</string>
|
||||
<string name="calendars_export_hint">اختر التقويمات التي تريد تضمينها في ملف .ics.</string>
|
||||
<string name="calendars_export_action">تصدير</string>
|
||||
<string name="calendars_restore_header">استعادة</string>
|
||||
<string name="calendars_restore_action">استعادة من ملف .ics</string>
|
||||
@@ -514,7 +514,7 @@
|
||||
<string name="settings_default_view_hint">طريقة العرض التي يفتحها Calendula عندما تقوم ببدءه.</string>
|
||||
<string name="settings_week_start_hint">اليوم الذي يبدأ به كل أسبوع، في جميع طُرق العرض والأدوات المصغّرة.</string>
|
||||
<string name="settings_time_format_hint">كيف الأوقات تُكتب في جميع أنحاء التطبيق. تلقائيًا يتبع إعداد نظامك.</string>
|
||||
<string name="settings_past_events_hint">ما الذي يفعله الجدول بالأحداث التي انتهت بالفعل.</string>
|
||||
<string name="settings_past_events_hint">ما الذي يفعله جدول بالأحداث التي انتهت بالفعل.</string>
|
||||
<string name="calendars_visibility_a11y">إظهار \"%1$s\"</string>
|
||||
<string name="calendars_visibility_notice_title">بعض التقويمات متوقفة</string>
|
||||
<string name="calendars_visibility_notice_message">Calendula يعرض التقويمات التي تم تشغيلها لهذا الجهاز، وبعض من خاصتك متوقفة. لرؤية أحداثهم، قم بتشغيلهم ضمن الإعدادات ← التقويمات.</string>
|
||||
@@ -732,7 +732,7 @@
|
||||
<string name="event_detail_title">العنوان</string>
|
||||
<string name="settings_licences">تراخيص مفتوحة المصدر</string>
|
||||
<string name="settings_licences_subtitle">المشاريع التي بنيت Calendula عليها</string>
|
||||
<string name="licences_title">التراخيص مفتوحة المصدر</string>
|
||||
<string name="licences_title">تراخيص مفتوحة المصدر</string>
|
||||
<string name="licences_intro">يتضمن Calendula العمل أدناه. اضغط على أي إدخال للحصول على شفرة المصدر الخاصة به.</string>
|
||||
<string name="licences_footer">يتم تضمين كل مشروع هنا دون تعديل، بموجب الترخيص الموضح.</string>
|
||||
<string name="state_failure_permission_body">يقرأ Calendula أحداثك مباشرةً من تقويم النظام، لذلك فأنه يحتاج للوصول إلى التقويم لعرض أي شيء.</string>
|
||||
@@ -778,30 +778,4 @@
|
||||
<string name="color_name_amber">أصفر كهرماني</string>
|
||||
<string name="calendars_restore_unavailable">لا تقويم يمكنه استيعاب الأحداث المستوردة بعد.</string>
|
||||
<string name="calendars_description_label">الوصف</string>
|
||||
<string name="app_name_calendar_alias">التقويم</string>
|
||||
<plurals name="view_multi_day">
|
||||
<item quantity="zero">%d يوم</item>
|
||||
<item quantity="one">%d يوم</item>
|
||||
<item quantity="two">%d يومان</item>
|
||||
<item quantity="few">%d أيام</item>
|
||||
<item quantity="many">%d يوم</item>
|
||||
<item quantity="other">%d يوم</item>
|
||||
</plurals>
|
||||
<string name="settings_visible_hours_start">من</string>
|
||||
<string name="settings_visible_hours_end">حتى</string>
|
||||
<string name="settings_multi_day_count">عرض الأيام المتعدّدة</string>
|
||||
<string name="settings_multi_day_count_hint">كم عدد الأيام التي يعرضها طريقة العرض متعدد الأيام جنبًا إلى جنب. اليوم يُفتح في العمود الأول.</string>
|
||||
<string name="settings_visible_hours">الساعات الظاهرة</string>
|
||||
<string name="settings_visible_hours_summary">حدّد مدى من الساعات للشاشة عند فتح طريقة عرض الأسبوع، متعددة الأيام أو اليوم</string>
|
||||
<string name="settings_visible_hours_on_summary">%1$s – %2$s يملأ الشاشة عند فتح طريقة عرض</string>
|
||||
<string name="settings_visible_hours_start_hint">الساعة في أعلى الشاشة عند فتح طريقة عرض الأسبوع، متعددة الأيام أو اليوم. لا تزال الساعات السابقة على بُعد تمرير.</string>
|
||||
<string name="settings_visible_hours_end_hint">الساعة في أسفل الشاشة عند فتح طريقة عرض. لا تزال الساعات اللاحقة على بُعد تمرير، و عمليات التكبير بالضّم حتى يتم فتح طريقة العرض مرة أخرى.</string>
|
||||
<string name="month_open_week">فتح الأسبوع %1$d</string>
|
||||
<string name="settings_timeline_scale_by_visible_hours">ضبط بواسطة الساعات الظاهرة</string>
|
||||
<string name="month_split_clear_filter">إظهار جدول الشهر كامل</string>
|
||||
<string name="timeline_earlier_events">أحداث سابقة</string>
|
||||
<string name="timeline_later_events">أحداث لاحقة</string>
|
||||
<string name="reminder_day_today">اليوم</string>
|
||||
<string name="event_detail_open_in_maps">فتح في الخرائط</string>
|
||||
<string name="link_open_failed">لا تطبيق يمكنه فتح هذا الرابط</string>
|
||||
</resources>
|
||||
|
||||
@@ -671,41 +671,4 @@
|
||||
<string name="state_failure_all_hidden_action">Кіраваньне календарамі</string>
|
||||
<string name="state_failure_no_import_target">Ніводны з календароў ня можа прымаць новыя падзеі.</string>
|
||||
<string name="state_failure_no_import_target_body">Календары адключаныя, у рэжыме «толькі чытаньне», знаходзяцца пад кіраваньнем іншай праграмы, або не сынхранізуюцца на гэтай прыладзе. Уключэце каляндар, які можа прымаць падзеі, або дадайце лякальны.</string>
|
||||
<string name="app_name_calendar_alias">Каляндар</string>
|
||||
<string name="state_failure_event_not_found">Гэтай падзеі больш тут няма.</string>
|
||||
<string name="state_failure_event_not_found_body">Магчыма яна выдалена ці перамешчана ў адключаны каляндар.</string>
|
||||
<string name="state_failure_provider_body">Сыстэмны каляндар не адказвае. Паўтарэце спробу празь нейкі час.</string>
|
||||
<string name="field_copy_action">Капіяваць</string>
|
||||
<string name="field_copied">Скапіявана ў буфэр абмену</string>
|
||||
<string name="field_copy_failed">Не атрымалася скапіяваць</string>
|
||||
<string name="month_open_week">Адкрыць тыдзень %1$d</string>
|
||||
<string name="month_split_clear_filter">Паказваць расклад на цэлы месяц</string>
|
||||
<string name="timeline_earlier_events">Ранейшыя падзеі</string>
|
||||
<string name="timeline_later_events">Пазьнейшыя падзеі</string>
|
||||
<string name="event_detail_title">Загаловак</string>
|
||||
<string name="event_detail_open_in_maps">Адкрыць на картах</string>
|
||||
<string name="link_open_failed">Гэтую спасылку ня можа адкрыць ніводны дадатак</string>
|
||||
<string name="reminder_after">праз %1$s</string>
|
||||
<string name="calendar_unnamed">(Бяз назвы)</string>
|
||||
<string name="onboarding_calendars_title">Месца для запісу вашых падзеяў</string>
|
||||
<string name="onboarding_calendars_body">На гэтай прыладзе пакуль няма календара. Calendula выконвае запіс у календары, якія ўжо ёсьць на тэлефоне, і патрабуецца прынамсі адзін.</string>
|
||||
<string name="onboarding_calendars_local_button">Стварыць каляндар на гэтай прыладзе</string>
|
||||
<string name="onboarding_calendars_caldav_button">Сынхранізацыя з сэрвэрам CalDAV</string>
|
||||
<string name="onboarding_calendars_skip_button">Ня зараз</string>
|
||||
<string name="onboarding_calendars_benefit_local_title">Застаецца на гэтым тэлефоне</string>
|
||||
<string name="onboarding_calendars_benefit_local_body">Каляндар на прыладзе ні з чым не сынхранізуецца, таму рабеце рэзэрвовыя копіі, каб захоўваць важныя падзеі.</string>
|
||||
<string name="onboarding_calendars_benefit_sync_title">Або наладзьце сынхранізацыю з уліковым запісам</string>
|
||||
<string name="onboarding_calendars_created_more_title">Прастора для іншых</string>
|
||||
<string name="onboarding_calendars_created_title">«%1$s» гатовы</string>
|
||||
<string name="onboarding_calendars_benefit_sync_body">Calendula паказвае ўліковыя запісы, якія ўжо сынхранізуюцца. Для выкананьня сынхранізацыі сэрвэры CalDAV патрабуець DAVx\\u2075.</string>
|
||||
<string name="onboarding_calendars_created_body">Ён застаецца на гэтай прыладзе. У ім будуць запісвацца новыя падзеі, пакуль ня будзе выбраны іншы каляндар.</string>
|
||||
<string name="onboarding_calendars_created_more_body">Праз мэню Налады → Календары можна задаць колькасьць, назвы і колеры наяўных календароў.</string>
|
||||
<string name="onboarding_calendars_created_continue">Далей</string>
|
||||
<string name="onboarding_hidden_title">Вашыя календары адключаныя</string>
|
||||
<string name="onboarding_hidden_body">Calendula паказвае календары, уключаныя на гэтай прыладзе, але цяпер няма ніводнага ўключанага. Уключэце тыя, якія вы хочаце бачыць, і яны зноў зьявяцца і ў іншых дадатках календароў.</string>
|
||||
<string name="onboarding_hidden_switch_all_button">Уключыць усе</string>
|
||||
<string name="onboarding_hidden_skip_button">Ня зараз</string>
|
||||
<string name="onboarding_hidden_failed">Гэты не атрымалася ўключыць. У мэню Налады → Календары знаходзяцца такія самыя перамыкачы.</string>
|
||||
<string name="onboarding_calendars_create_failed">Не атрымалася стварыць каляндар з-за адмовы сховішча вашай прылады.</string>
|
||||
<string name="reminder_day_today">Сёньня</string>
|
||||
</resources>
|
||||
|
||||
@@ -439,7 +439,7 @@
|
||||
<string name="settings_month_header">Měsíční zobrazení</string>
|
||||
<string name="settings_month_view_style">Styl měsíčního zobrazení</string>
|
||||
<string name="month_style_paged">Stránky</string>
|
||||
<string name="month_style_paged_summary">Na obrazovce se zobrazuje jeden měsíc. Přejetím prstem doleva nebo doprava můžete změnit měsíc, přejetím prstem nahoru zobrazíte měsíční přehled.</string>
|
||||
<string name="month_style_paged_summary">Na obrazovce se zobrazuje jeden měsíc. Přejetím prstem doleva nebo doprava můžete změnit měsíc.</string>
|
||||
<string name="month_style_continuous">Posouvání měsíců</string>
|
||||
<string name="month_style_continuous_summary">Každý měsíc je zobrazen pod vlastním nadpisem, od následujícího je oddělen malou mezerou.</string>
|
||||
<string name="month_style_dense">Nepřerušené týdny</string>
|
||||
@@ -719,7 +719,7 @@
|
||||
<string name="onboarding_hidden_title">Vaše kalendáře jsou vypnuté</string>
|
||||
<string name="onboarding_hidden_body">Calendula zobrazuje kalendáře, které má tento telefon zapnuté, což není momentálně žádný. Zapněte si kalendáře, které chcete vidět – vaše ostatní aplikace kalendáře je pak také zobrazí.</string>
|
||||
<string name="onboarding_hidden_switch_all_button">Zapnout všechny kalendáře</string>
|
||||
<string name="onboarding_hidden_skip_button">Později</string>
|
||||
<string name="onboarding_hidden_skip_button">Teď ne</string>
|
||||
<string name="onboarding_hidden_failed">To se nepodařilo zapnout. V Nastavení → Kalendáře jsou stejné přepínače.</string>
|
||||
<string name="onboarding_calendars_create_failed">Kalendář se nepodařilo vytvořit. Úložiště kalendáře ve vašem telefonu jej odmítlo vytvořit.</string>
|
||||
<string name="calendars_description_label">Popis</string>
|
||||
@@ -736,32 +736,4 @@
|
||||
<string name="color_name_purple">Purpurová</string>
|
||||
<string name="color_name_pink">Růžová</string>
|
||||
<string name="calendars_restore_unavailable">Žádný z kalendářů zatím nedokáže importované události přijmout.</string>
|
||||
<string name="app_name_calendar_alias">Kalendář</string>
|
||||
<string name="month_open_week">Otevřít týden %1$d</string>
|
||||
<string name="month_split_clear_filter">Zobrazit celou měsíční agendu</string>
|
||||
<string name="timeline_earlier_events">Dřívější události</string>
|
||||
<string name="timeline_later_events">Pozdější události</string>
|
||||
<string name="event_detail_open_in_maps">Otevřít na mapě</string>
|
||||
<string name="link_open_failed">Žádná aplikace nemůže tento odkaz otevřít</string>
|
||||
<string name="reminder_day_today">Dnes</string>
|
||||
<plurals name="view_multi_day">
|
||||
<item quantity="one">%d den</item>
|
||||
<item quantity="few">%d dny</item>
|
||||
<item quantity="many">%d dnů</item>
|
||||
<item quantity="other">%d dnů</item>
|
||||
</plurals>
|
||||
<string name="settings_multi_day_count">Vícedenní zobrazení</string>
|
||||
<string name="settings_multi_day_count_hint">Počet dní zobrazujících se ve vícedenním přehledu vedle sebe. Dnešek se otevírá v prvním sloupci.</string>
|
||||
<string name="settings_timeline_scale_by_visible_hours">Nastaveno v části Viditelné hodiny</string>
|
||||
<string name="settings_visible_hours">Viditelné hodiny</string>
|
||||
<string name="settings_visible_hours_summary">Při otevření týdenního, vícedenního nebo denního zobrazení se rozsah hodin přizpůsobí obrazovce</string>
|
||||
<string name="settings_visible_hours_on_summary">%1$s – %2$s vyplní obrazovku při otevření zobrazení</string>
|
||||
<string name="settings_visible_hours_start">Od</string>
|
||||
<string name="settings_visible_hours_end">Do</string>
|
||||
<string name="settings_visible_hours_start_hint">Čas zobrazený v horní části obrazovky při otevření týdenního, vícedenního nebo denního přehledu. Předchozí časy lze zobrazit posunutím obrazovky.</string>
|
||||
<string name="settings_visible_hours_end_hint">Hodina v dolní části obrazovky v okamžiku, kdy se otevře zobrazení. Další hodiny se zobrazí skrolováním, přiblížením prstů se obraz dočasně zvětší.</string>
|
||||
<string name="settings_support_kofi">Ko-fi</string>
|
||||
<string name="settings_support_kofi_hint">Jednorázové příspěvky</string>
|
||||
<string name="settings_support_liberapay">Liberapay</string>
|
||||
<string name="settings_support_liberapay_hint">Pravidelné příspěvky</string>
|
||||
</resources>
|
||||
|
||||
@@ -715,16 +715,4 @@
|
||||
<item quantity="one">%d Termin konnte nicht hinzugefügt werden.</item>
|
||||
<item quantity="other">%d Termine konnten nicht hinzugefügt werden.</item>
|
||||
</plurals>
|
||||
<string name="app_name_calendar_alias">Kalender</string>
|
||||
<string name="timeline_earlier_events">Frühere Termine</string>
|
||||
<string name="timeline_later_events">Spätere Termine</string>
|
||||
<string name="link_open_failed">Keine App kann diesen Link öffnen</string>
|
||||
<string name="reminder_day_today">Heute</string>
|
||||
<plurals name="view_multi_day">
|
||||
<item quantity="one">%d Tag</item>
|
||||
<item quantity="other">%d Tage</item>
|
||||
</plurals>
|
||||
<string name="settings_visible_hours">Sichtbare Stunden</string>
|
||||
<string name="settings_visible_hours_start">Von</string>
|
||||
<string name="settings_visible_hours_end">Bis</string>
|
||||
</resources>
|
||||
|
||||
@@ -672,5 +672,4 @@
|
||||
<item quantity="many">Les événements %d n\'ont pas pu être ajoutés.</item>
|
||||
<item quantity="other">Les événements %d n\'ont pas pu être ajoutés.</item>
|
||||
</plurals>
|
||||
<string name="app_name_calendar_alias">Calendrier</string>
|
||||
</resources>
|
||||
|
||||
@@ -243,7 +243,7 @@
|
||||
<string name="settings_time_format_auto">Automatico</string>
|
||||
<string name="settings_time_format_12h">12 ore</string>
|
||||
<string name="settings_time_format_24h">24 ore</string>
|
||||
<string name="settings_hour_lines">Grglia oraria</string>
|
||||
<string name="settings_hour_lines">Righe per le ore</string>
|
||||
<string name="settings_hour_lines_summary">Mostra una riga separatrice per ogni ora nella vista Settimana e Giorno</string>
|
||||
<string name="settings_dim_completed">Attenua gli eventi completati</string>
|
||||
<string name="settings_dim_completed_summary">Attenua gli eventi conclusi nella vista Mese e Settimana</string>
|
||||
@@ -413,7 +413,7 @@
|
||||
<string name="settings_default_reminder">Promemoria per eventi standard</string>
|
||||
<string name="settings_notifications_subtitle">Promemoria</string>
|
||||
<string name="shortcut_new_event_short">Nuovo evento</string>
|
||||
<string name="event_edit_managed_hint">Gestito da “%1$s” — titolo, data e ripetizioni sono sincronizzate dai tuoi contatti. Promemoria e note sono modificabili dall\'utente.</string>
|
||||
<string name="event_edit_managed_hint">Gestito da “%1$s” - titolo, data e ripetizioni sono sincronizzate dai tuoi contatti. Promemoria e note sono modificabili dall\'utente.</string>
|
||||
<string name="settings_font_headings">Carattere del titolo</string>
|
||||
<string name="settings_font_body">Carattere del corpo</string>
|
||||
<string name="settings_font_system">Lingua di sistema</string>
|
||||
@@ -503,7 +503,7 @@
|
||||
<string name="event_move_undone">Ripianificazione annullata</string>
|
||||
<string name="event_move_failed">Impossibile ripianificare l\'evento</string>
|
||||
<string name="event_move_write_denied">Calendula ha bisogno dei permessi di scrittura per ripianificare gli eventi</string>
|
||||
<string name="event_move_gone">Questo evento è stato eliminato</string>
|
||||
<string name="event_move_gone">Questo evento è stato eliminato.</string>
|
||||
<string name="event_move_blocked_series_end">Non è possibile ripianificare un evento oltre la data di fine della sua serie</string>
|
||||
<string name="event_edit_timezone_device">Fuso orario del dispositivo</string>
|
||||
<string name="event_edit_timezone_device_summary">Segue il fuso orario in cui ti trovi</string>
|
||||
@@ -657,83 +657,4 @@
|
||||
<string name="import_done_failed_label">Non aggiunto</string>
|
||||
<string name="import_done_failed_note">Alcuni eventi non possono essere aggiunti e sono stati esclusi.</string>
|
||||
<string name="import_done_stopped_title">Importazione interrotta</string>
|
||||
<string name="app_name_calendar_alias">Calendario</string>
|
||||
<string name="state_failure_permission_body">Calendula legge i tuoi eventi direttamente dal calendario di sistema, per questo ha bisogno dell\'accesso al calendario per poterli visualizzare.</string>
|
||||
<string name="state_failure_no_calendars_body">Aggiungi un account a questo dispositivo e i relativi calendari verranno visualizzati qui.</string>
|
||||
<string name="state_failure_all_hidden">Tutti i tuoi calendari sono disattivati.</string>
|
||||
<string name="state_failure_all_hidden_body">Riattiva almeno un calendario per visualizzarne gli eventi.</string>
|
||||
<string name="state_failure_no_import_target">Nessun calendario può accettare nuovi eventi.</string>
|
||||
<string name="state_failure_no_import_target_body">I calendari attivi sono di sola lettura, gestiti da un\'altra app o non sincronizzati con questo dispositivo. Attiva un calendario in cui è possibile inserire eventi oppure aggiungine uno locale.</string>
|
||||
<string name="state_failure_event_not_found">Questo evento non è più disponibile.</string>
|
||||
<string name="state_failure_event_not_found_body">Potrebbe essere stato cancellato oppure spostato in un calendario disattivato.</string>
|
||||
<string name="state_failure_provider_body">Il calendario di sistema non ha risposto. Riprova a breve.</string>
|
||||
<string name="timeline_earlier_events">Eventi precedenti</string>
|
||||
<string name="timeline_later_events">Eventi successivi</string>
|
||||
<string name="month_open_week">Settimana aperta %1$d</string>
|
||||
<string name="month_split_clear_filter">Visualizza il calendario completo del mese</string>
|
||||
<string name="event_detail_open_in_maps">Apri su Maps</string>
|
||||
<string name="link_open_failed">Nessuna app è in grado di aprire questo link</string>
|
||||
<string name="calendar_unnamed">(Senza nome)</string>
|
||||
<string name="onboarding_calendars_title">Un posto dove inserire i tuoi eventi</string>
|
||||
<string name="onboarding_calendars_body">Questo dispositivo non ha ancora un calendario. Calendula scrive sui calendari già presenti sul telefono — ne serve almeno uno.</string>
|
||||
<string name="onboarding_calendars_local_button">Crea un calendario su questo dispositivo</string>
|
||||
<string name="onboarding_calendars_caldav_button">Sincronizzazione con un server CalDAV</string>
|
||||
<string name="onboarding_calendars_skip_button">Non ora</string>
|
||||
<string name="onboarding_calendars_benefit_local_title">Rimane su questo telefono</string>
|
||||
<string name="onboarding_calendars_benefit_local_body">Il calendario del dispositivo non si sincronizza con nessun altro dispositivo, quindi esegui un backup se gli eventi sono importanti.</string>
|
||||
<string name="onboarding_calendars_benefit_sync_title">Oppure sincronizza da un account</string>
|
||||
<string name="onboarding_calendars_benefit_sync_body">Calendula mostra quali account sono già sincronizzati. I server CalDAV necessitano di DAVx⁵ per eseguire la sincronizzazione.</string>
|
||||
<string name="onboarding_calendars_created_title">“%1$s” è pronto</string>
|
||||
<string name="onboarding_calendars_created_body">Si trova su questo dispositivo e gli eventi nuovi vengono inseriti lì, a meno che non si scelga un altro calendario.</string>
|
||||
<string name="onboarding_calendars_created_more_title">C\'è spazio per altro</string>
|
||||
<string name="onboarding_calendars_created_more_body">Da Impostazioni → Calendari puoi crearne quanti ne vuoi e rinominare o cambiare il colore di quelli già presenti.</string>
|
||||
<string name="onboarding_calendars_created_continue">Continua</string>
|
||||
<string name="onboarding_hidden_title">I vostri calendari sono disattivati</string>
|
||||
<string name="onboarding_hidden_body">Calendula mostra i calendari attivi su questo telefono e, al momento, non ce n’è nessuno. Attiva quelli che desideri visualizzare: anche le altre app di calendario li mostreranno nuovamente.</string>
|
||||
<string name="onboarding_hidden_switch_all_button">Attivali tutti</string>
|
||||
<string name="onboarding_hidden_skip_button">Non ora</string>
|
||||
<string name="onboarding_hidden_failed">Non sono riuscito ad attivarlo. In Impostazioni → Calendari ci sono gli stessi pulsanti di attivazione.</string>
|
||||
<string name="onboarding_calendars_create_failed">Impossibile creare il calendario. La memoria del calendario del tuo telefono non lo ha accettato.</string>
|
||||
<string name="reminder_day_today">Oggi</string>
|
||||
<plurals name="view_multi_day">
|
||||
<item quantity="one">Giorno</item>
|
||||
<item quantity="many">Giorni</item>
|
||||
<item quantity="other">Giorno</item>
|
||||
</plurals>
|
||||
<string name="settings_multi_day_count">Visualizzazione su più giorni</string>
|
||||
<string name="settings_multi_day_count_hint">La vista più giorni mostra, affiancati, i giorni. La data odierna è visualizzata nella prima colonna.</string>
|
||||
<string name="settings_timeline_scale_by_visible_hours">Imposta in base alle ore di visibilità</string>
|
||||
<string name="settings_visible_hours">Orari di apertura</string>
|
||||
<string name="settings_visible_hours_summary">Adatta la gamma di ore alla schermata all\'apertura della visualizzazione settimanale, plurigiornaliera o giornaliera</string>
|
||||
<string name="settings_visible_hours_on_summary">%1$s – %2$s riempie lo schermo all’apertura di una vista</string>
|
||||
<string name="settings_visible_hours_start">Da</string>
|
||||
<string name="settings_visible_hours_end">Fino a</string>
|
||||
<string name="settings_visible_hours_start_hint">L\'ora visualizzata nella parte superiore dello schermo quando si apre la vista settimanale, plurigiornaliera o giornaliera. Le ore precedenti sono comunque accessibili scorrendo la schermata.</string>
|
||||
<string name="settings_visible_hours_end_hint">L\'ora visualizzata nella parte inferiore dello schermo quando si apre una schermata. Le ore successive sono ancora raggiungibili scorrendo lo schermo, mentre pizzicando lo schermo si esegue lo zoom fino a quando la schermata non viene riaperta.</string>
|
||||
<string name="settings_licences">Licenze open source</string>
|
||||
<string name="settings_licences_subtitle">I progetti su cui si basa Calendula</string>
|
||||
<string name="calendars_description_label">Descrizione</string>
|
||||
<string name="color_name_mauve">Rosa malva</string>
|
||||
<string name="color_name_red">Rosso</string>
|
||||
<string name="color_name_orange">Arancione</string>
|
||||
<string name="color_name_amber">Ambra</string>
|
||||
<string name="color_name_olive">Oliva</string>
|
||||
<string name="color_name_green">Verde</string>
|
||||
<string name="color_name_teal">Verde acqua</string>
|
||||
<string name="color_name_cyan">Ciano</string>
|
||||
<string name="color_name_blue">Blu</string>
|
||||
<string name="color_name_indigo">Indaco</string>
|
||||
<string name="color_name_purple">Viola</string>
|
||||
<string name="color_name_pink">Rosa</string>
|
||||
<string name="calendars_restore_unavailable">Al momento nessun calendario è in grado di gestire gli eventi importati.</string>
|
||||
<plurals name="import_done_stopped">
|
||||
<item quantity="one">Il calendario ha smesso di accettare eventi a metà dell\'operazione. L\'evento %d presente nel file non è stato aggiunto.</item>
|
||||
<item quantity="many">Il calendari hanno smesso di accettare eventi a metà dell\'operazione. Gli eventi %d presenti nel file non sono stati aggiunti.</item>
|
||||
<item quantity="other">Il calendario ha smesso di accettare eventi a metà dell\'operazione. L\'evento %d presente nel file non è stato aggiunto.</item>
|
||||
</plurals>
|
||||
<plurals name="import_done_failed">
|
||||
<item quantity="one">Non è stato possibile aggiungere l\'evento %d.</item>
|
||||
<item quantity="many">Non è stato possibile aggiungere gli eventi %d.</item>
|
||||
<item quantity="other">Non è stato possibile aggiungere l\'evento %d.</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
|
||||
@@ -25,10 +25,10 @@
|
||||
<string name="settings_section_notifications">通知</string>
|
||||
<string name="drawer_jump_to_date">日付に移動</string>
|
||||
<string name="state_failure_permission">カレンダーへのアクセス権限が必要です。</string>
|
||||
<string name="permission_rationale_body">Calendula で予定を表示・管理するには、カレンダーへのアクセス許可が必要です。</string>
|
||||
<string name="permission_rationale_body">Calendulaで予定を表示・管理するには、カレンダーへのアクセス許可が必要です。</string>
|
||||
<string name="permission_request_button">カレンダーへのアクセスを許可</string>
|
||||
<string name="permission_denied_title">カレンダーへのアクセスが拒否されました</string>
|
||||
<string name="permission_denied_body">Calendula はカレンダーへのアクセス権がないと予定を表示できません。システム設定から再度許可をしてください。</string>
|
||||
<string name="permission_denied_body">Calendulaはカレンダーへのアクセス権がないと予定を表示できません。システム設定から再度許可をお願いします。</string>
|
||||
<string name="permission_open_settings_button">システム設定を開く</string>
|
||||
<string name="permission_retry_button">再試行</string>
|
||||
<string name="permission_benefit_private_title">プライバシーを重視した設計</string>
|
||||
@@ -79,41 +79,4 @@
|
||||
<string name="settings_theme">テーマ</string>
|
||||
<string name="settings_theme_system">システム</string>
|
||||
<string name="settings_dynamic_color">ダイナミックカラー</string>
|
||||
<string name="view_agenda">予定リスト</string>
|
||||
<string name="agenda_header_today">今日</string>
|
||||
<string name="agenda_header_tomorrow">明日</string>
|
||||
<string name="widget_agenda_label">Calendula 予定リスト</string>
|
||||
<string name="settings_agenda_header">予定リスト</string>
|
||||
<string name="settings_agenda_range">予定リストの範囲</string>
|
||||
<string name="settings_section_appearance">外観</string>
|
||||
<string name="state_failure_unknown">問題が発生しました。</string>
|
||||
<string name="state_failure_permission_body">Calendula はシステムのカレンダーから直接予定を読み取るため、予定を表示するにはカレンダーへのアクセスが必要です。</string>
|
||||
<string name="state_failure_permission_action">アクセスを許可</string>
|
||||
<string name="state_failure_no_calendars">カレンダーが設定されていません。</string>
|
||||
<string name="state_failure_no_calendars_body">このデバイスにカレンダーアカウントを追加すると、そのカレンダーがここに表示されます。</string>
|
||||
<string name="state_failure_no_calendars_action">システムのカレンダー設定を開く</string>
|
||||
<string name="state_failure_all_hidden">すべてのカレンダーがオフになっています。</string>
|
||||
<string name="state_failure_all_hidden_body">予定を表示するには、少なくとも 1 つのカレンダーを再度有効にしてください。</string>
|
||||
<string name="state_failure_all_hidden_action">カレンダーの管理</string>
|
||||
<string name="state_failure_no_import_target">新しい予定を追加できるカレンダーがありません。</string>
|
||||
<string name="state_failure_provider">カレンダーを読み込めませんでした。</string>
|
||||
<string name="event_move_failed">予定を移動できませんでした</string>
|
||||
<string name="event_edit_save_failed">予定を保存できませんでした</string>
|
||||
<string name="month_a11y_today_prefix">今日</string>
|
||||
<string name="day_today_action">今日</string>
|
||||
<string name="settings_time_format_auto">自動</string>
|
||||
<string name="agenda_range_day">今日</string>
|
||||
<string name="agenda_range_custom_hint">日</string>
|
||||
<string name="dialog_save">保存</string>
|
||||
<string name="calendars_description_label">説明</string>
|
||||
<string name="special_dates_calendar_birthday">誕生日</string>
|
||||
<string name="reminder_day_today">今日</string>
|
||||
<string name="agenda_today_action">今日</string>
|
||||
<string name="widget_today">今日</string>
|
||||
<plurals name="duration_minutes">
|
||||
<item quantity="other">%d分</item>
|
||||
</plurals>
|
||||
<string name="reminder_day_tomorrow">明日</string>
|
||||
<string name="state_failure_no_import_target_body">有効になっているカレンダーが読み取り専用であるか、別のアプリによって管理されているか、またはこのデバイスに同期されていません。予定を追加できるカレンダーを有効にするか、ローカルのカレンダーを追加してください。</string>
|
||||
<string name="state_failure_event_not_found">この予定はすでに存在しません。</string>
|
||||
</resources>
|
||||
|
||||
@@ -377,7 +377,7 @@
|
||||
<string name="settings_today_toolbar">Knop Vandaag in werkbalk</string>
|
||||
<string name="settings_today_toolbar_summary">Een knop om naar vandaag te gaan tonen in de werkbalk, in plaats van een zwevende knop</string>
|
||||
<string name="settings_app_name">Appnaam</string>
|
||||
<string name="settings_app_name_summary">Calendula als “Agenda” tonen in je launcher. Alleen de naam in de launcher verandert; het pictogram kan na het wisselen op een andere plek komen te staan.</string>
|
||||
<string name="settings_app_name_summary">Calendula als “Calendar” tonen in je launcher. Alleen de naam in de launcher verandert; het pictogram kan na het wisselen op een andere plek komen te staan.</string>
|
||||
<string name="settings_time_format">Tijdnotatie</string>
|
||||
<string name="settings_time_format_auto">Automatisch</string>
|
||||
<string name="settings_time_format_12h">12-uursnotatie (2:00 PM)</string>
|
||||
@@ -441,7 +441,7 @@
|
||||
<string name="settings_month_header">Maandweergave</string>
|
||||
<string name="settings_month_view_style">Stijl van maandweergave</string>
|
||||
<string name="month_style_paged">Pagina\'s</string>
|
||||
<string name="month_style_paged_summary">Eén maand vult het scherm. Veeg naar links of rechts om van maand te wisselen, of veeg omhoog om de agenda van deze maand te tonen.</string>
|
||||
<string name="month_style_paged_summary">Eén maand vult het scherm. Veeg naar links of rechts om van maand te wisselen.</string>
|
||||
<string name="month_style_continuous">Doorlopende maanden</string>
|
||||
<string name="month_style_continuous_summary">Elke maand staat onder een eigen kop, met wat ruimte tot de volgende maand.</string>
|
||||
<string name="month_style_dense">Naadloze weken</string>
|
||||
@@ -694,26 +694,4 @@
|
||||
<string name="special_dates_default_title_birthday">Verjaardag van {name} ({year})</string>
|
||||
<string name="special_dates_default_title_anniversary">Jubileum van {name} ({year})</string>
|
||||
<string name="special_dates_default_title_custom">{name}</string>
|
||||
<string name="app_name_calendar_alias">Agenda</string>
|
||||
<string name="month_open_week">Week %1$d openen</string>
|
||||
<string name="month_split_clear_filter">Agenda van de hele maand tonen</string>
|
||||
<string name="timeline_earlier_events">Eerdere gebeurtenissen</string>
|
||||
<string name="timeline_later_events">Latere gebeurtenissen</string>
|
||||
<string name="event_detail_open_in_maps">Op kaart tonen</string>
|
||||
<string name="link_open_failed">Er is geen app die deze link kan openen</string>
|
||||
<string name="reminder_day_today">Vandaag</string>
|
||||
<plurals name="view_multi_day">
|
||||
<item quantity="one">%d dag</item>
|
||||
<item quantity="other">%d dagen</item>
|
||||
</plurals>
|
||||
<string name="settings_multi_day_count">Meerdaagse weergave</string>
|
||||
<string name="settings_multi_day_count_hint">Hoeveel dagen de meerdaagse weergave naast elkaar toont. Vandaag staat in de eerste kolom.</string>
|
||||
<string name="settings_timeline_scale_by_visible_hours">Ingesteld via Zichtbare uren</string>
|
||||
<string name="settings_visible_hours">Zichtbare uren</string>
|
||||
<string name="settings_visible_hours_summary">Een reeks uren passend op het scherm tonen bij het openen van de week-, meerdaagse of dagweergave</string>
|
||||
<string name="settings_visible_hours_on_summary">%1$s – %2$s vult het scherm bij het openen van een weergave</string>
|
||||
<string name="settings_visible_hours_start">Van</string>
|
||||
<string name="settings_visible_hours_end">Tot</string>
|
||||
<string name="settings_visible_hours_start_hint">Het uur bovenaan het scherm bij het openen van de week-, meerdaagse of dagweergave. Eerdere uren blijven bereikbaar door te scrollen.</string>
|
||||
<string name="settings_visible_hours_end_hint">Het uur onderaan het scherm bij het openen van een weergave. Latere uren blijven bereikbaar door te scrollen, en in- of uitzoomen met twee vingers geldt totdat de weergave opnieuw wordt geopend.</string>
|
||||
</resources>
|
||||
|
||||
@@ -355,8 +355,4 @@
|
||||
<string name="settings_time_format_auto">Автоматически</string>
|
||||
<string name="settings_time_format_12h">12-часовой формат времени</string>
|
||||
<string name="settings_time_format_24h">24-часовой формат времени</string>
|
||||
<string name="onboarding_calendars_benefit_sync_body">Calendula отображает учетные записи, которые уже синхронизируются. Для синхронизации серверам CalDAV требуется DAVx⁵.</string>
|
||||
<string name="onboarding_calendars_created_title">«%1$s» готов</string>
|
||||
<string name="onboarding_calendars_created_body">Он остаётся на данном устройстве. В него будут записываться новые события, пока не будет выбран иной календарь.</string>
|
||||
<string name="onboarding_calendars_created_more_title">Место для других</string>
|
||||
</resources>
|
||||
|
||||
@@ -441,21 +441,6 @@
|
||||
<string name="widget_prev_month">Previous month</string>
|
||||
<string name="widget_next_month">Next month</string>
|
||||
<string name="widget_today">Today</string>
|
||||
<string name="widget_config_background">Background</string>
|
||||
<string name="widget_config_background_solid">Solid</string>
|
||||
<string name="widget_config_background_tinted">Tinted</string>
|
||||
<string name="widget_config_background_transparent">Transparent</string>
|
||||
<string name="widget_config_theme_follow_app">Follow app</string>
|
||||
<string name="widget_config_content">Content</string>
|
||||
<string name="widget_config_range">Time range</string>
|
||||
<string name="widget_config_text_size">Text size</string>
|
||||
<string name="widget_config_load_failed">Couldn\'t read this widget\'s settings.</string>
|
||||
<string name="widget_config_preview_failed">Preview unavailable</string>
|
||||
<string name="widget_config_save_failed">Couldn\'t save the widget. Try again.</string>
|
||||
<!-- A widget's name and its number, when there are several of the same kind: "Calendula agenda 2" -->
|
||||
<string name="widget_numbered">%1$s %2$d</string>
|
||||
<!-- A placed widget's background and theme: "Tinted · Follow app" -->
|
||||
<string name="widget_appearance_summary">%1$s · %2$s</string>
|
||||
|
||||
<!-- Calendar filter (M3) -->
|
||||
<string name="filter_title">Calendars</string>
|
||||
@@ -569,7 +554,7 @@
|
||||
<!-- %1$d is the event text size as a percentage of the smallest step, e.g. 130. -->
|
||||
<string name="settings_widget_size_summary">Event text %1$d%%</string>
|
||||
<string name="settings_agenda_show_today">Always show today</string>
|
||||
<string name="settings_agenda_show_today_hint">Keep today at the top of the agenda, even once nothing is left today. Each widget has its own setting.</string>
|
||||
<string name="settings_agenda_show_today_hint">Keep today at the top of the agenda and its widget, even once nothing is left today.</string>
|
||||
<string name="settings_agenda_range_bar">Range bar</string>
|
||||
<string name="settings_agenda_range_bar_hint">Show a bar at the top of the agenda naming the dates shown, with a button to switch the range for the session</string>
|
||||
<string name="agenda_range_day">Today</string>
|
||||
@@ -660,15 +645,12 @@
|
||||
<string name="settings_event_form_subtitle">Default fields and behaviour</string>
|
||||
<string name="settings_notifications_subtitle">Reminders and delivery</string>
|
||||
<string name="settings_special_dates_subtitle">Contact birthdays & anniversaries</string>
|
||||
<string name="settings_widgets_subtitle">Your widgets, Quick Settings tile</string>
|
||||
<string name="settings_widgets_subtitle">Agenda widget, Quick Settings tile</string>
|
||||
<string name="settings_backup_subtitle">Export, import, automatic backup</string>
|
||||
|
||||
<!-- Widgets & tiles (#69) -->
|
||||
<string name="settings_section_widgets">Widgets & tiles</string>
|
||||
<string name="settings_widgets_hint">Each widget has its own look and options. Tap one below, or long-press it on your home screen and choose Edit. Add a widget by long-pressing your home screen.</string>
|
||||
<string name="settings_widgets_hint_legacy">Each widget has its own look and options. Tap one below to change it. Add a widget by long-pressing your home screen.</string>
|
||||
<string name="settings_widgets_placed">On your home screen</string>
|
||||
<string name="settings_widgets_none">No Calendula widgets on your home screen yet.</string>
|
||||
<string name="settings_widgets_hint">Settings for the home-screen widgets and the Quick Settings tile. Add a widget by long-pressing your home screen.</string>
|
||||
|
||||
<!-- Backup & restore (#69) — the rows themselves reuse the calendars_* strings -->
|
||||
<string name="settings_section_backup">Backup & restore</string>
|
||||
@@ -720,10 +702,6 @@
|
||||
<string name="settings_licences_subtitle">The projects Calendula is built on</string>
|
||||
<string name="settings_about_privacy">Privacy policy</string>
|
||||
<string name="settings_about_support">Support development</string>
|
||||
<string name="settings_support_kofi">Ko-fi</string>
|
||||
<string name="settings_support_kofi_hint">One-off tips</string>
|
||||
<string name="settings_support_liberapay">Liberapay</string>
|
||||
<string name="settings_support_liberapay_hint">Recurring donations</string>
|
||||
<string name="settings_about_version">Version %1$s</string>
|
||||
<string name="settings_about_logo_desc">Calendula app icon</string>
|
||||
<string name="settings_report_problem">Report a problem</string>
|
||||
@@ -880,8 +858,7 @@
|
||||
<string name="about_source_url" translatable="false">https://codeberg.org/jlmakiola/calendula</string>
|
||||
<string name="about_license_url" translatable="false">https://codeberg.org/jlmakiola/calendula/src/branch/main/LICENSE</string>
|
||||
<string name="about_privacy_url" translatable="false">https://jeanlucmakiola.de/calendula/privacy</string>
|
||||
<string name="about_support_kofi_url" translatable="false">https://ko-fi.com/jeanlucmakiola</string>
|
||||
<string name="about_support_liberapay_url" translatable="false">https://liberapay.com/Jean-Luc_Makiola/donate</string>
|
||||
<string name="about_support_url" translatable="false">https://ko-fi.com/jeanlucmakiola</string>
|
||||
<string name="about_translate_url" translatable="false">https://weblate.dev.jeanlucmakiola.de/engage/calendula/</string>
|
||||
|
||||
<!-- Crash reporting: a captured report the user can submit, by hand, as a
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user