Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17b3753ad3 | ||
|
|
efc36f1bff | ||
|
|
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 |
@@ -15,7 +15,7 @@ jobs:
|
||||
detect:
|
||||
# Gitea only; see the same guard in release.yaml.
|
||||
if: github.repository_owner == 'makiolaj'
|
||||
runs-on: docker
|
||||
runs-on: light
|
||||
outputs:
|
||||
is_beta: ${{ steps.v.outputs.is_beta }}
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
beta:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.is_beta == 'true'
|
||||
runs-on: docker
|
||||
runs-on: gradle
|
||||
env:
|
||||
ANDROID_HOME: /opt/android-sdk
|
||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
# secrets. repository_owner differs between the two forges regardless of
|
||||
# URL, proxy or instance rename, so this closes it permanently.
|
||||
if: github.repository_owner == 'makiolaj'
|
||||
runs-on: docker
|
||||
runs-on: light
|
||||
outputs:
|
||||
is_release: ${{ steps.v.outputs.is_release }}
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
release:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: docker
|
||||
runs-on: gradle
|
||||
env:
|
||||
ANDROID_HOME: /opt/android-sdk
|
||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||
|
||||
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 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. 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]).
|
||||
- **Scroll back to earlier days in the agenda.** Scroll up past today to see earlier days' events, loaded a month at a time. Past days follow the past-events setting: Dim fades them, and with Hide the agenda still starts at today ([#367]).
|
||||
- **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]).
|
||||
|
||||
### 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]).
|
||||
|
||||
## [2.22.5] — 2026-10-07
|
||||
|
||||
### Fixed
|
||||
@@ -1848,4 +1864,13 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#361]: https://codeberg.org/jlmakiola/calendula/issues/361
|
||||
[#363]: https://codeberg.org/jlmakiola/calendula/issues/363
|
||||
[#364]: https://codeberg.org/jlmakiola/calendula/issues/364
|
||||
[#367]: https://codeberg.org/jlmakiola/calendula/issues/367
|
||||
[#373]: https://codeberg.org/jlmakiola/calendula/issues/373
|
||||
[#141]: https://codeberg.org/jlmakiola/calendula/issues/141
|
||||
[#153]: https://codeberg.org/jlmakiola/calendula/issues/153
|
||||
[#339]: https://codeberg.org/jlmakiola/calendula/issues/339
|
||||
[#387]: https://codeberg.org/jlmakiola/calendula/issues/387
|
||||
[#271]: https://codeberg.org/jlmakiola/calendula/issues/271
|
||||
[#181]: https://codeberg.org/jlmakiola/calendula/issues/181
|
||||
[#306]: https://codeberg.org/jlmakiola/calendula/issues/306
|
||||
[#178]: https://codeberg.org/jlmakiola/calendula/issues/178
|
||||
|
||||
@@ -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 = 2220599
|
||||
versionName = "2.22.5"
|
||||
versionCode = 2230004
|
||||
versionName = "2.23.0-beta.4"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -175,7 +175,10 @@ dependencies {
|
||||
implementation("de.jeanlucmakiola.floret:core-time")
|
||||
implementation("de.jeanlucmakiola.floret:core-locale")
|
||||
implementation("de.jeanlucmakiola.floret:core-crash")
|
||||
implementation("de.jeanlucmakiola.floret:core-di")
|
||||
implementation("de.jeanlucmakiola.floret:core-prefs")
|
||||
implementation("de.jeanlucmakiola.floret:core-reminders")
|
||||
implementation("de.jeanlucmakiola.floret:core-android")
|
||||
implementation("de.jeanlucmakiola.floret:identity")
|
||||
implementation("de.jeanlucmakiola.floret:components")
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.provider.CalendarContract
|
||||
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 de.jeanlucmakiola.calendula.domain.toEditForm
|
||||
import kotlinx.datetime.TimeZone
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Moving a synced series keeps its deleted and edited occurrences, although the
|
||||
* moved row has no `_sync_id` for exception rows to attach to.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MoveEventExceptionsTest {
|
||||
|
||||
@get:Rule
|
||||
val permissionRule: GrantPermissionRule = GrantPermissionRule.grant(
|
||||
Manifest.permission.READ_CALENDAR,
|
||||
Manifest.permission.WRITE_CALENDAR,
|
||||
)
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val resolver = context.contentResolver
|
||||
private val ds = AndroidCalendarDataSource(context)
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
|
||||
private val day = 86_400_000L
|
||||
private val start = 1_893_492_000_000L // 2030-01-01T10:00Z
|
||||
private val hour = 3_600_000L
|
||||
|
||||
/** "day offset:title" of every instance on [calendarId]. */
|
||||
private fun occurrences(calendarId: Long): List<String> =
|
||||
ds.instances(start - day, start + 10 * day)
|
||||
.filter { it.calendarId == calendarId }
|
||||
.map { "${(it.start.toEpochMilliseconds() - start) / day}:${it.title}" }
|
||||
.sorted()
|
||||
|
||||
@Test
|
||||
fun movedSeriesKeepsCancelledAndModifiedOccurrences() {
|
||||
val a = ds.createLocalCalendar("move-test-a", 0xFF336699.toInt(), null)
|
||||
val b = ds.createLocalCalendar("move-test-b", 0xFF993366.toInt(), null)
|
||||
try {
|
||||
val src = insertSyncedSeries(a)
|
||||
ds.deleteOccurrence(src, start + 2 * day)
|
||||
val occurrence = ds.eventDetail(src, 540)!!.toEditForm(start + 3 * day, start + 3 * day + hour, zone)
|
||||
ds.updateOccurrence(src, start + 3 * day, occurrence, occurrence.copy(title = "edited"), 540)
|
||||
val expected = listOf("0:series", "1:series", "3:edited", "4:series")
|
||||
assertThat(occurrences(a)).isEqualTo(expected)
|
||||
|
||||
val form = ds.eventDetail(src, 540)!!.toEditForm(start, start + hour, zone)
|
||||
ds.moveEvent(src, b, form, form, 540)
|
||||
|
||||
assertThat(occurrences(a)).isEmpty()
|
||||
assertThat(occurrences(b)).isEqualTo(expected)
|
||||
} finally {
|
||||
ds.deleteCalendar(a)
|
||||
ds.deleteCalendar(b)
|
||||
}
|
||||
}
|
||||
|
||||
/** A daily series carrying a `_sync_id`, so its exceptions are real exception rows. */
|
||||
private fun insertSyncedSeries(calendarId: Long): Long {
|
||||
val accountName = resolver.query(
|
||||
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
|
||||
arrayOf(CalendarContract.Calendars.ACCOUNT_NAME), null, null, null,
|
||||
)!!.use { it.moveToFirst(); it.getString(0) }
|
||||
val uri = CalendarContract.Events.CONTENT_URI.buildUpon()
|
||||
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
|
||||
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, accountName)
|
||||
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||
.build()
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Events.CALENDAR_ID, calendarId)
|
||||
put(CalendarContract.Events.TITLE, "series")
|
||||
put(CalendarContract.Events.DTSTART, start)
|
||||
put(CalendarContract.Events.DURATION, "P3600S")
|
||||
put(CalendarContract.Events.RRULE, "FREQ=DAILY;COUNT=5")
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, "UTC")
|
||||
put(CalendarContract.Events._SYNC_ID, "move-test-series")
|
||||
}
|
||||
return ContentUris.parseId(resolver.insert(uri, values)!!)
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package de.jeanlucmakiola.calendula.ui.detail
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* The platform phone matcher on real text. It reads the region off the SIM,
|
||||
* so these stick to international numbers and to text that must never match
|
||||
* anywhere — domestic formats depend on the device they run on.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PhoneLinksPlatformTest {
|
||||
|
||||
private fun numbers(text: String) = findPhoneLinks(text).map { it.number }
|
||||
|
||||
@Test
|
||||
fun internationalNumbers_areFound() {
|
||||
assertThat(numbers("Call +49 30 1234567 or +1 646-558-8656"))
|
||||
.containsExactly("+49301234567", "+16465588656")
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trunkZero_isDroppedWithOrWithoutSpace() {
|
||||
assertThat(numbers("+49 (0)151 2345678")).containsExactly("+491512345678")
|
||||
assertThat(numbers("+49 (0) 151 2345678")).containsExactly("+491512345678")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun neighbouringNumbers_doNotJoinTheNumber() {
|
||||
assertThat(numbers("Start 10:00 +49 151 2345678")).containsExactly("+491512345678")
|
||||
assertThat(numbers("Call +49 151 2345678 2 times")).containsExactly("+491512345678")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun conferenceDialIn_keepsMeetingId() {
|
||||
assertThat(numbers("One tap mobile: +16465588656,,85012345678# US"))
|
||||
.containsExactly("+16465588656,,85012345678#")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun meetingIds_areNotNumbers() {
|
||||
assertThat(numbers("Meeting ID: 850 1234 5678")).isEmpty()
|
||||
assertThat(numbers("Conference ID: 123 456 789#")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun datesAndTimes_areNotNumbers() {
|
||||
assertThat(numbers("2026-10-14 10:00")).isEmpty()
|
||||
assertThat(numbers("Am 14.10.2026 10:00 Uhr")).isEmpty()
|
||||
assertThat(numbers("14.10.26 - 16.10.26")).isEmpty()
|
||||
assertThat(numbers("10:00 - 11:30")).isEmpty()
|
||||
assertThat(numbers("Schuljahr 2025/2026, Season 2025-2026")).isEmpty()
|
||||
assertThat(numbers("1.10.2026 - 3.10.2026")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun amounts_areNotNumbers() {
|
||||
assertThat(numbers("Budget 1.250.000 EUR, 1 250 000 EUR")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isbn_isNotANumber() {
|
||||
assertThat(numbers("ISBN 978-3-16-148410-0")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extension_becomesAPause() {
|
||||
assertThat(numbers("+1 212 555 1234 ext. 567")).containsExactly("+12125551234,567")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun webexAccessCode_isNotANumber() {
|
||||
assertThat(numbers("Meeting number (access code): 2732 123 4567")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun commaSeparatedNumbers_stayApart() {
|
||||
assertThat(numbers("+49 30 1234567,+49 151 2345678"))
|
||||
.containsExactly("+49301234567", "+491512345678")
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dottedTimes_areNotNumbers() {
|
||||
assertThat(numbers("Beginn 9.30 – 12.00 Uhr")).isEmpty()
|
||||
assertThat(numbers("10.00-11.30")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun schemelessUrlDigits_areNotNumbers() {
|
||||
assertThat(numbers("zoom.us/j/85012345678")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun webLinkDigits_areNotNumbers() {
|
||||
assertThat(numbers("https://zoom.us/j/85012345678?pwd=1")).isEmpty()
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,19 @@
|
||||
<data android:mimeType="application/ics" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Text shared or selected in another app opens a prefilled new
|
||||
event (MainActivity.sharedTextExtrasOrNull, issue #306). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
<intent-filter android:label="@string/process_text_create_event">
|
||||
<action android:name="android.intent.action.PROCESS_TEXT" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Let another app or widget (e.g. the Todo Agenda widget) launch us
|
||||
to create a new event, the way the AOSP calendar accepts it:
|
||||
ACTION_INSERT on the events *dir* mime type, carrying the new
|
||||
|
||||
@@ -25,12 +25,19 @@ import androidx.core.content.IntentCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.floret.prefs.isDark
|
||||
import de.jeanlucmakiola.floret.prefs.isPureBlack
|
||||
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
|
||||
@@ -50,8 +57,11 @@ import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||
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.launch
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
@@ -59,41 +69,31 @@ 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() {
|
||||
|
||||
@Inject lateinit var calendarRepository: CalendarRepository
|
||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||
|
||||
// Which of light/dark the system bars are drawn for. The styles installed
|
||||
// in onCreate read this field live, so androidx's config-change replay
|
||||
// picks up the in-app override instead of the night resource qualifier.
|
||||
private var systemBarsDark = false
|
||||
|
||||
// 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)
|
||||
private val requests = ExternalRequests(
|
||||
onExternalFormClosed = {
|
||||
insertFormOpen = false
|
||||
if (launchedForInsert) finish()
|
||||
},
|
||||
)
|
||||
|
||||
// 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)
|
||||
// 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.
|
||||
private var insertFormOpen = false
|
||||
|
||||
// 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). Consumed once by
|
||||
// CalendarHost, which opens it in the create form for review.
|
||||
private var requestedInsert by mutableStateOf<InsertRequest?>(null)
|
||||
|
||||
// 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)
|
||||
// Started just for an external create, so closing its form returns to the
|
||||
// app that asked instead of leaving the calendar on top of it.
|
||||
private var launchedForInsert = false
|
||||
|
||||
// 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
|
||||
@@ -115,9 +115,21 @@ class MainActivity : AppCompatActivity() {
|
||||
return
|
||||
}
|
||||
|
||||
systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
|
||||
val systemDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
|
||||
Configuration.UI_MODE_NIGHT_YES
|
||||
// The stored theme only arrives after the first frame, so a Light, Dark
|
||||
// or Black override starts from its last mirrored copy: the matching
|
||||
// backdrop and bar icons instead of the night qualifier's (#181).
|
||||
val cachedTheme = WindowThemeCache.read(this)
|
||||
cachedTheme?.windowBackgroundRes()?.let { window.setBackgroundDrawableResource(it) }
|
||||
systemBarsDark = (cachedTheme ?: ThemeMode.SYSTEM).isDark(systemDark)
|
||||
applyEdgeToEdge()
|
||||
lifecycleScope.launch {
|
||||
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
|
||||
// time the activity is recreated — on a rotation, or on a restart after
|
||||
// the system reclaimed the process. Re-running a launch that opens one
|
||||
@@ -128,27 +140,28 @@ class MainActivity : AppCompatActivity() {
|
||||
//
|
||||
// Only those channels are held back. An .ics import or a prefilled
|
||||
// create form keeps no saved state of its own, so re-reading the intent
|
||||
// is what carries one through a recreation.
|
||||
// is what carries one through a recreation — the create form only while
|
||||
// it's still open.
|
||||
val replaying = savedInstanceState != null || intent.isRelaunch()
|
||||
if (!replaying) {
|
||||
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
||||
requestedEditKey = intent.editEventKeyOrNull()
|
||||
requests.detailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
||||
requests.editKey = intent.editEventKeyOrNull()
|
||||
}
|
||||
requestedNav = intent.navRequestOrNull()
|
||||
requests.nav = intent.navRequestOrNull()
|
||||
?.takeUnless { replaying && it is WidgetNavRequest.OpenEvent }
|
||||
requestedImportUri = intent.importUriOrNull()
|
||||
requestedInsert = intent.insertRequestOrNull()
|
||||
requests.importUri = intent.importUriOrNull()
|
||||
val insertOpenBefore = savedInstanceState?.getBoolean(KEY_INSERT_FORM_OPEN) == true
|
||||
requests.insert = intent.createRequestOrNull()?.takeIf { !replaying || insertOpenBefore }
|
||||
insertFormOpen = requests.insert != null
|
||||
launchedForInsert = savedInstanceState?.getBoolean(KEY_LAUNCHED_FOR_INSERT)
|
||||
?: (requests.insert != 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()
|
||||
val darkTheme = when (settings.themeMode) {
|
||||
ThemeMode.SYSTEM -> isSystemInDarkTheme()
|
||||
ThemeMode.LIGHT -> false
|
||||
ThemeMode.DARK -> true
|
||||
}
|
||||
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.
|
||||
@@ -187,6 +200,7 @@ class MainActivity : AppCompatActivity() {
|
||||
CalendulaTheme(
|
||||
darkTheme = darkTheme,
|
||||
dynamicColor = settings.dynamicColor,
|
||||
pureBlack = settings.themeMode.isPureBlack,
|
||||
typography = typography,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
@@ -199,17 +213,7 @@ class MainActivity : AppCompatActivity() {
|
||||
) {
|
||||
RootScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
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 },
|
||||
requestedEditKey = requestedEditKey,
|
||||
onEditKeyConsumed = { requestedEditKey = null },
|
||||
requests = requests,
|
||||
)
|
||||
}
|
||||
// A persistent corner marker so a debug build is never
|
||||
@@ -261,13 +265,22 @@ class MainActivity : AppCompatActivity() {
|
||||
CrashReporter.markHealthy(this)
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.putBoolean(KEY_INSERT_FORM_OPEN, insertFormOpen)
|
||||
outState.putBoolean(KEY_LAUNCHED_FOR_INSERT, launchedForInsert)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it }
|
||||
intent.navRequestOrNull()?.let { requestedNav = it }
|
||||
intent.importUriOrNull()?.let { requestedImportUri = it }
|
||||
intent.insertRequestOrNull()?.let { requestedInsert = it }
|
||||
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
|
||||
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requests.detailKey = it }
|
||||
intent.navRequestOrNull()?.let { requests.nav = it }
|
||||
intent.importUriOrNull()?.let { requests.importUri = it }
|
||||
intent.createRequestOrNull()?.let {
|
||||
requests.insert = it
|
||||
insertFormOpen = true
|
||||
}
|
||||
intent.editEventKeyOrNull()?.let { requests.editKey = it }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -288,7 +301,9 @@ class MainActivity : AppCompatActivity() {
|
||||
private fun Intent.importUriOrNull(): Uri? {
|
||||
val uri = when (action) {
|
||||
Intent.ACTION_VIEW -> data
|
||||
// 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;
|
||||
@@ -333,6 +348,36 @@ class MainActivity : AppCompatActivity() {
|
||||
return InsertRequest(form, if (namesEnd) ImportSource.Insert else ImportSource.InsertOpenEnded)
|
||||
}
|
||||
|
||||
/** Any external request to create an event: shared text or an insert intent. */
|
||||
private fun Intent.createRequestOrNull(): InsertRequest? = sharedTextRequestOrNull() ?: insertRequestOrNull()
|
||||
|
||||
/**
|
||||
* Plain text shared into us (`ACTION_SEND`) or picked from a text-selection
|
||||
* menu (`ACTION_PROCESS_TEXT`), as a new event at the default time (#306).
|
||||
*/
|
||||
private fun Intent.sharedTextRequestOrNull(): InsertRequest? {
|
||||
val shared = when (action) {
|
||||
Intent.ACTION_SEND -> if (type == SHARED_TEXT_TYPE) {
|
||||
sharedTextOrNull(
|
||||
subject = getCharSequenceExtra(Intent.EXTRA_SUBJECT)?.toString(),
|
||||
text = getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString(),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
Intent.ACTION_PROCESS_TEXT ->
|
||||
sharedTextOrNull(subject = null, text = getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)?.toString())
|
||||
else -> null
|
||||
} ?: return null
|
||||
val form = buildInsertEventForm(
|
||||
title = shared.title,
|
||||
description = shared.description,
|
||||
zone = TimeZone.currentSystemDefault(),
|
||||
now = Clock.System.now(),
|
||||
)
|
||||
return InsertRequest(form, ImportSource.InsertOpenEnded)
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -458,6 +503,9 @@ class MainActivity : AppCompatActivity() {
|
||||
// The calendar provider's authority/host. A date tap arrives as
|
||||
// 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 const val KEY_INSERT_FORM_OPEN = "insert_form_open"
|
||||
private const val KEY_LAUNCHED_FOR_INSERT = "launched_for_insert"
|
||||
|
||||
private const val EXTRA_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID"
|
||||
private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
|
||||
|
||||
@@ -5,12 +5,10 @@ import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
@@ -28,6 +26,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import de.jeanlucmakiola.floret.android.hasPermission
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.time.Clock
|
||||
@@ -133,8 +132,7 @@ class BackupWorker(
|
||||
|
||||
private fun notifyFailure(context: Context) {
|
||||
val canPost = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
context.hasPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
if (!canPost) return
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
manager.createNotificationChannel(
|
||||
|
||||
+189
-313
@@ -1,13 +1,12 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.floret.time.toEpochMillis
|
||||
import de.jeanlucmakiola.floret.android.hasPermission
|
||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||
import android.Manifest
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.database.ContentObserver
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
@@ -15,7 +14,6 @@ import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.CalendarContract
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
|
||||
@@ -28,7 +26,6 @@ import de.jeanlucmakiola.calendula.domain.curatedForPicker
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventSearch
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
|
||||
@@ -36,9 +33,11 @@ import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.semanticReminderMinutes
|
||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
||||
import de.jeanlucmakiola.calendula.domain.toEditForm
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
@@ -76,21 +75,13 @@ interface CalendarDataSource {
|
||||
|
||||
/**
|
||||
* The event-colour palette the calendar's account publishes
|
||||
* (`CalendarContract.Colors`, `TYPE_EVENT`), curated for display — deduped,
|
||||
* thinned to visually distinct swatches when oversized (CalDAV adapters
|
||||
* publish all ~147 CSS3 names, #22) and hue-sorted; see [curatedForPicker].
|
||||
* (`CalendarContract.Colors`, `TYPE_EVENT`), every key in provider order.
|
||||
* Empty when the account exposes no palette (most local calendars, some
|
||||
* CalDAV) — the signal that a custom colour can only be written as a raw
|
||||
* `EVENT_COLOR`, which a synced calendar may drop on its next sync.
|
||||
*/
|
||||
fun eventColorPalette(calendarId: Long): List<EventColorOption>
|
||||
|
||||
/**
|
||||
* The same palette **uncurated** — every key the account publishes, in
|
||||
* provider order. Curation is a display concession (it folds look-alikes and
|
||||
* drops the neutrals outright from an oversized palette), so matching a
|
||||
* colour that came from outside the account has to run against the full set:
|
||||
* those keys are all the calendar accepts. See [eventColorPalette].
|
||||
* `EVENT_COLOR`, which a synced calendar may drop on its next sync. The
|
||||
* picker shows it curated ([curatedForPicker]); matching a colour that came
|
||||
* from outside the account runs against this full set, since those keys are
|
||||
* all the calendar accepts.
|
||||
*/
|
||||
fun publishedEventColors(calendarId: Long): List<EventColorOption>
|
||||
|
||||
@@ -238,8 +229,10 @@ interface CalendarDataSource {
|
||||
* and cancelled occurrences) to [targetCalendarId], returning the new
|
||||
* `Events._ID`. `CALENDAR_ID` is sync-adapter-owned and can't be updated in
|
||||
* place, so this is copy+delete: the row is re-inserted on the target (its
|
||||
* `UID_2445` preserved), its exceptions, reminders and editable guests
|
||||
* replayed, the [original]→[updated] field edits applied, then the source
|
||||
* `UID_2445` preserved), its reminders and editable guests copied, its
|
||||
* exceptions turned into EXDATEs (modified ones also into standalone events,
|
||||
* as the new row has no `_sync_id` to attach them to), the
|
||||
* [original]→[updated] field edits applied, then the source
|
||||
* deleted. The source is removed only once the copy fully succeeds; a failure
|
||||
* before that rolls the new event back, leaving the original untouched.
|
||||
* [allDayReminderTimeMinutes]: see [insertEvent].
|
||||
@@ -323,8 +316,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
private val registeredObservers = mutableSetOf<ContentObserver>()
|
||||
|
||||
private fun hasCalendarPermission(): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
context.hasPermission(Manifest.permission.READ_CALENDAR)
|
||||
|
||||
/**
|
||||
* Attach any not-yet-registered observers to the provider, but only once the
|
||||
@@ -358,7 +350,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
CalendarProjection.COLUMNS,
|
||||
null, null,
|
||||
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC",
|
||||
)?.use { it.mapAll(::toCalendarSource) } ?: emptyList()
|
||||
)?.use { c -> c.mapRows { toCalendarSource() } } ?: emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -375,7 +367,20 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
)
|
||||
.build()
|
||||
|
||||
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
|
||||
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long =
|
||||
insertLocalCalendar(displayName, color, "create local calendar") { putDescription(description) }
|
||||
|
||||
/**
|
||||
* Insert an owned, visible, syncing calendar on the Calendula local account;
|
||||
* [extra] adds the columns that tell the kinds apart. [what] names the write
|
||||
* in the failure — never the calendar name, which can reach a crash report.
|
||||
*/
|
||||
private fun insertLocalCalendar(
|
||||
displayName: String,
|
||||
color: Int,
|
||||
what: String,
|
||||
extra: ContentValues.() -> Unit,
|
||||
): Long {
|
||||
val name = displayName.trim()
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
||||
@@ -391,11 +396,9 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
)
|
||||
put(CalendarContract.Calendars.VISIBLE, 1)
|
||||
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
|
||||
putDescription(description)
|
||||
extra()
|
||||
}
|
||||
val uri = resolver.insert(localCalendarsUri(), values)
|
||||
// No calendar name in the message — it can reach a crash report.
|
||||
?: throw WriteFailedException("create local calendar")
|
||||
val uri = resolver.insert(localCalendarsUri(), values) ?: throw WriteFailedException(what)
|
||||
return ContentUris.parseId(uri)
|
||||
}
|
||||
|
||||
@@ -460,33 +463,15 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
}
|
||||
|
||||
override fun canWriteCalendars(): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
context.hasPermission(Manifest.permission.WRITE_CALENDAR)
|
||||
|
||||
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
||||
val name = displayName.trim()
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
||||
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||
put(CalendarContract.Calendars.OWNER_ACCOUNT, LOCAL_ACCOUNT_NAME)
|
||||
put(CalendarContract.Calendars.NAME, name)
|
||||
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
|
||||
put(CalendarContract.Calendars.CALENDAR_COLOR, color)
|
||||
put(
|
||||
CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL,
|
||||
CalendarContract.Calendars.CAL_ACCESS_OWNER,
|
||||
)
|
||||
put(CalendarContract.Calendars.VISIBLE, 1)
|
||||
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
|
||||
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long =
|
||||
insertLocalCalendar(displayName, color, "create managed calendar") {
|
||||
// The marker that identifies this as our special-dates calendar,
|
||||
// independent of the (user-editable) display name. CAL_SYNC1 already
|
||||
// holds the description, so the marker lives in CAL_SYNC2.
|
||||
put(MANAGED_MARKER_COLUMN, markerFor(type))
|
||||
}
|
||||
val uri = resolver.insert(localCalendarsUri(), values)
|
||||
?: throw WriteFailedException("create managed calendar")
|
||||
return ContentUris.parseId(uri)
|
||||
}
|
||||
|
||||
override fun findManagedCalendars(): List<ManagedCalendarRow> = resolver.query(
|
||||
CalendarContract.Calendars.CONTENT_URI,
|
||||
@@ -550,22 +535,16 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
// Seeded once, then never re-touched by sync — so a user who moves a
|
||||
// birthday reminder keeps their change. Encoded against the upcoming
|
||||
// occurrence, not the ancient DTSTART anchor (wrong timezone offset).
|
||||
val anchor = Instant.ofEpochMilli(times.dtStartMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||
insertReminderRows(
|
||||
eventId,
|
||||
managedAllDayReminderMinutes(anchor, form.reminders, allDayReminderTimeMinutes),
|
||||
managedAllDayReminderMinutes(utcDate(times.dtStartMillis), form.reminders, allDayReminderTimeMinutes),
|
||||
)
|
||||
return eventId
|
||||
}
|
||||
|
||||
override fun updateManagedFields(eventId: Long, values: Map<String, Any?>) {
|
||||
if (values.isEmpty()) return
|
||||
val rows = resolver.update(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||
values.toContentValues(),
|
||||
null, null,
|
||||
)
|
||||
if (rows == 0) throw WriteFailedException("update managed event id=$eventId")
|
||||
updateEventRow(eventId, values.toContentValues(), "update managed event id=$eventId")
|
||||
}
|
||||
|
||||
override fun applyManagedCalendarReminders(
|
||||
@@ -573,38 +552,21 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
allDayReminderTimeMinutes: Int,
|
||||
minutes: List<Int>,
|
||||
) {
|
||||
resolver.query(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
arrayOf(CalendarContract.Events._ID, CalendarContract.Events.DTSTART),
|
||||
// Only our own mirror events, so a stray user event in this calendar
|
||||
// isn't all-day-re-encoded or stripped of its own reminders.
|
||||
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
|
||||
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||
"${CalendarContract.Events.UID_2445} LIKE ?",
|
||||
arrayOf(calendarId.toString(), "$MANAGED_UID_PREFIX%"),
|
||||
null,
|
||||
)?.use { c ->
|
||||
while (c.moveToNext()) {
|
||||
val eventId = c.getLong(0)
|
||||
// Managed events are all-day: DTSTART is a UTC midnight anchor.
|
||||
val anchor = Instant.ofEpochMilli(c.getLong(1))
|
||||
.atZone(ZoneOffset.UTC).toLocalDate()
|
||||
reconcileReminders(
|
||||
eventId,
|
||||
managedAllDayReminderMinutes(anchor, minutes, allDayReminderTimeMinutes),
|
||||
)
|
||||
}
|
||||
// Only our own mirror events, so a stray user event in this calendar
|
||||
// isn't all-day-re-encoded or stripped of its own reminders.
|
||||
queryManagedEvents(calendarId).forEach { event ->
|
||||
// Managed events are all-day: DTSTART is a UTC midnight anchor.
|
||||
reconcileReminders(
|
||||
event.eventId,
|
||||
managedAllDayReminderMinutes(utcDate(event.dtStartMillis), minutes, allDayReminderTimeMinutes),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
|
||||
ensureObserversRegistered()
|
||||
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
||||
ContentUris.appendId(this, beginMillis)
|
||||
ContentUris.appendId(this, endMillis)
|
||||
}.build()
|
||||
return resolver.query(
|
||||
uri,
|
||||
instancesUri(beginMillis, endMillis),
|
||||
InstanceProjection.COLUMNS,
|
||||
// Hide cancelled occurrences: "delete only this event" writes a
|
||||
// cancelled exception for the one instance (#47). A NULL status is a
|
||||
@@ -617,7 +579,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
"${CalendarContract.Events.DELETED} = 0",
|
||||
null,
|
||||
CalendarContract.Instances.BEGIN + " ASC",
|
||||
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
||||
)?.use { c -> c.mapRowsNotNull { toEventInstance() } } ?: emptyList()
|
||||
}
|
||||
|
||||
override fun searchEvents(query: String): List<SearchCandidate> {
|
||||
@@ -721,13 +683,9 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
endMillis: Long,
|
||||
soonestFirst: Boolean,
|
||||
): Pair<Long, Long>? {
|
||||
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
||||
ContentUris.appendId(this, beginMillis)
|
||||
ContentUris.appendId(this, endMillis)
|
||||
}.build()
|
||||
val order = CalendarContract.Instances.BEGIN + if (soonestFirst) " ASC" else " DESC"
|
||||
return resolver.query(
|
||||
uri,
|
||||
instancesUri(beginMillis, endMillis),
|
||||
arrayOf(CalendarContract.Instances.BEGIN, CalendarContract.Instances.END),
|
||||
"${CalendarContract.Instances.EVENT_ID} = ?",
|
||||
arrayOf(eventId.toString()),
|
||||
@@ -741,7 +699,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
val attendees = queryAttendees(eventId)
|
||||
val reminders = queryReminders(eventId)
|
||||
return resolver.query(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||
eventUri(eventId),
|
||||
EventDetailProjection.COLUMNS,
|
||||
null, null, null,
|
||||
)?.use { c ->
|
||||
@@ -752,9 +710,6 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||
publishedEventColors(calendarId).curatedForPicker()
|
||||
|
||||
override fun publishedEventColors(calendarId: Long): List<EventColorOption> {
|
||||
val account = calendarAccount(calendarId) ?: return emptyList()
|
||||
return resolver.query(
|
||||
@@ -807,11 +762,10 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
null,
|
||||
CalendarContract.Events.DTSTART + " ASC",
|
||||
)?.use { c ->
|
||||
c.mapAll {
|
||||
val reader = CursorColumnReader(c)
|
||||
val eventId = reader.getLong(EventExportProjection.IDX_ID)
|
||||
val calendarId = reader.getLong(EventExportProjection.IDX_CALENDAR_ID)
|
||||
reader.toIcsEvent(
|
||||
c.mapRows {
|
||||
val eventId = getLong(EventExportProjection.IDX_ID)
|
||||
val calendarId = getLong(EventExportProjection.IDX_CALENDAR_ID)
|
||||
toIcsEvent(
|
||||
reminderMinutes = queryReminders(eventId).map { it.minutes },
|
||||
calendarName = names[calendarId],
|
||||
allDayReminderTimeMinutes = allDayReminderTimeMinutes,
|
||||
@@ -846,7 +800,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
calendarId = calendarId,
|
||||
// Preserve the file's UID so a re-import dedups against it; mint one
|
||||
// only when the source event carried none.
|
||||
uid = event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula",
|
||||
uid = event.uid?.takeIf { it.isNotBlank() } ?: mintUid(),
|
||||
palette = colorPalette,
|
||||
)
|
||||
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
|
||||
@@ -859,8 +813,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
// (see [importedAllDayReminderDate]).
|
||||
val reminderDate = if (event.isAllDay) {
|
||||
importedAllDayReminderDate(
|
||||
startDate = Instant.ofEpochMilli(event.start.toEpochMilliseconds())
|
||||
.atZone(ZoneOffset.UTC).toLocalDate(),
|
||||
startDate = utcDate(event.start.toEpochMilliseconds()),
|
||||
recurrenceRule = event.recurrenceRule,
|
||||
today = LocalDate.now(zone),
|
||||
)
|
||||
@@ -878,24 +831,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
} else {
|
||||
minutes
|
||||
}
|
||||
val reminder = ContentValues().apply {
|
||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||
put(CalendarContract.Reminders.MINUTES, providerMinutes)
|
||||
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||
}
|
||||
// The event row is already in. A reminder that won't attach costs
|
||||
// that one alarm, not the event: throwing here would have the import
|
||||
// count an event it did create as failed, and the retry would then
|
||||
// skip it as a duplicate.
|
||||
val attached = try {
|
||||
resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Reminder insert threw for imported event $eventId", e)
|
||||
null
|
||||
}
|
||||
if (attached == null) {
|
||||
Log.w(TAG, "Failed to attach reminder ($minutes min) to imported event $eventId")
|
||||
}
|
||||
insertReminderRow(eventId, providerMinutes)
|
||||
}
|
||||
return eventId
|
||||
}
|
||||
@@ -951,18 +887,13 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
// back to a stable synthesised UID at export time (deriveIcsUid).
|
||||
val values = buildEventInsertValues(
|
||||
form,
|
||||
uid = "${UUID.randomUUID()}@calendula",
|
||||
uid = mintUid(),
|
||||
times = form.toWriteTimes(ZoneId.systemDefault()),
|
||||
).toContentValues().apply {
|
||||
// A null colour just leaves both columns unset (the event inherits
|
||||
// its calendar's colour), so only the key/raw cases are written.
|
||||
when {
|
||||
form.colorKey != null ->
|
||||
put(CalendarContract.Events.EVENT_COLOR_KEY, form.colorKey)
|
||||
form.color != null -> put(CalendarContract.Events.EVENT_COLOR, form.color)
|
||||
}
|
||||
}
|
||||
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
||||
) + eventColorColumns(form.colorKey, form.color)
|
||||
// A new row has nothing to clear: unset columns inherit the
|
||||
// calendar's colour, so only the key/raw values are written.
|
||||
.filterValues { it != null }
|
||||
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
|
||||
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
|
||||
val eventId = ContentUris.parseId(uri)
|
||||
seedReminders(eventId, form, allDayReminderTimeMinutes)
|
||||
@@ -983,16 +914,32 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
|
||||
/** Insert one `METHOD_ALERT` reminder row per raw provider offset. */
|
||||
private fun insertReminderRows(eventId: Long, encodedMinutes: List<Int>) {
|
||||
encodedMinutes.forEach { minutes ->
|
||||
val reminder = ContentValues().apply {
|
||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||
}
|
||||
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
||||
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
||||
}
|
||||
encodedMinutes.forEach { insertReminderRow(eventId, it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Best effort: the event row is already in, so a reminder that won't attach
|
||||
* costs that one alarm, not the event. Throwing would report an event that
|
||||
* was created as failed — and an import retry would then skip it as a
|
||||
* duplicate.
|
||||
*/
|
||||
private fun insertReminderRow(
|
||||
eventId: Long,
|
||||
minutes: Int,
|
||||
method: Int = CalendarContract.Reminders.METHOD_ALERT,
|
||||
) {
|
||||
val reminder = ContentValues().apply {
|
||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||
put(CalendarContract.Reminders.METHOD, method)
|
||||
}
|
||||
val attached = try {
|
||||
resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Reminder insert threw for event $eventId", e)
|
||||
null
|
||||
}
|
||||
if (attached == null) Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1029,12 +976,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
zone = ZoneId.systemDefault(),
|
||||
)
|
||||
if (values.isNotEmpty()) {
|
||||
val rows = resolver.update(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||
values.toContentValues(),
|
||||
null, null,
|
||||
)
|
||||
if (rows == 0) throw WriteFailedException("update event id=$eventId")
|
||||
updateEventRow(eventId, values.toContentValues(), "update event id=$eventId")
|
||||
}
|
||||
// Untouched reminder sets are left alone so unrelated edits can't
|
||||
// disturb provider rows the form never knew about. The diff is on the
|
||||
@@ -1067,16 +1009,17 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
?: throw WriteFailedException("read event to move id=$eventId")
|
||||
// Keep the source UID so an .ics backup dedups and a sync adapter can
|
||||
// recognise the moved event; mint one only if the source carried none.
|
||||
val uid = master.uid?.takeIf { it.isNotEmpty() } ?: "${UUID.randomUUID()}@calendula"
|
||||
val uid = master.uid?.takeIf { it.isNotEmpty() } ?: mintUid()
|
||||
val newEventId = resolver.insert(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
buildMovedMasterValues(master.event, targetCalendarId, uid).toContentValues(),
|
||||
)?.let(ContentUris::parseId)
|
||||
?: throw WriteFailedException("insert moved event into calendar id=$targetCalendarId")
|
||||
val detached = mutableListOf<Long>()
|
||||
try {
|
||||
copyReminderRows(fromEventId = eventId, toEventId = newEventId)
|
||||
insertAttendees(newEventId, editableAttendees(eventId))
|
||||
copyExceptions(fromEventId = eventId, toEventId = newEventId)
|
||||
copyExceptions(eventId, newEventId, targetCalendarId, allDayReminderTimeMinutes, detached)
|
||||
// Apply the user's field edits exactly as a same-calendar "all
|
||||
// events" save would: the moved master carries the source's values,
|
||||
// so the dirty diff against [original] writes only what changed
|
||||
@@ -1086,6 +1029,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
// Undo the partial copy so a failed move leaves nothing behind; the
|
||||
// source is still intact (it's deleted only past this point).
|
||||
runCatching { deleteEvent(newEventId) }
|
||||
detached.forEach(::rollBackDetached)
|
||||
throw t
|
||||
}
|
||||
deleteEvent(eventId)
|
||||
@@ -1094,7 +1038,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
|
||||
/** The master row of the event to move, as a verbatim-insert snapshot. */
|
||||
private fun queryMoveMaster(eventId: Long): MoveMaster? = resolver.query(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||
eventUri(eventId),
|
||||
MoveMasterProjection.COLUMNS,
|
||||
null, null, null,
|
||||
)?.use { c ->
|
||||
@@ -1106,8 +1050,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
title = r.getString(MoveMasterProjection.IDX_TITLE).orEmpty(),
|
||||
isAllDay = r.getInt(MoveMasterProjection.IDX_ALL_DAY) != 0,
|
||||
dtStartMillis = r.getLong(MoveMasterProjection.IDX_DTSTART),
|
||||
dtEndMillis = r.getLong(MoveMasterProjection.IDX_DTEND)
|
||||
.takeUnless { r.isNull(MoveMasterProjection.IDX_DTEND) },
|
||||
dtEndMillis = r.getLongOrNull(MoveMasterProjection.IDX_DTEND),
|
||||
duration = r.getString(MoveMasterProjection.IDX_DURATION),
|
||||
rrule = r.getString(MoveMasterProjection.IDX_RRULE)?.takeIf { it.isNotBlank() },
|
||||
rdate = r.getString(MoveMasterProjection.IDX_RDATE),
|
||||
@@ -1115,8 +1058,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
timezone = r.getString(MoveMasterProjection.IDX_EVENT_TIMEZONE),
|
||||
availability = r.getInt(MoveMasterProjection.IDX_AVAILABILITY),
|
||||
accessLevel = r.getInt(MoveMasterProjection.IDX_ACCESS_LEVEL),
|
||||
status = r.getInt(MoveMasterProjection.IDX_STATUS)
|
||||
.takeUnless { r.isNull(MoveMasterProjection.IDX_STATUS) },
|
||||
status = r.getIntOrNull(MoveMasterProjection.IDX_STATUS),
|
||||
location = r.getString(MoveMasterProjection.IDX_LOCATION),
|
||||
description = r.getString(MoveMasterProjection.IDX_DESCRIPTION),
|
||||
),
|
||||
@@ -1133,16 +1075,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
arrayOf(fromEventId.toString()),
|
||||
null,
|
||||
)?.use { c ->
|
||||
while (c.moveToNext()) {
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Reminders.EVENT_ID, toEventId)
|
||||
put(CalendarContract.Reminders.MINUTES, c.getInt(0))
|
||||
put(CalendarContract.Reminders.METHOD, c.getInt(1))
|
||||
}
|
||||
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, values) == null) {
|
||||
Log.w(TAG, "Failed to copy a reminder to moved event $toEventId")
|
||||
}
|
||||
}
|
||||
while (c.moveToNext()) insertReminderRow(toEventId, minutes = c.getInt(0), method = c.getInt(1))
|
||||
} ?: Unit
|
||||
|
||||
/**
|
||||
@@ -1162,44 +1095,33 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay every exception of the series [fromEventId] against the moved series
|
||||
* [toEventId]. The copy preserved the recurrence skeleton, so each occurrence
|
||||
* time still resolves: a cancelled occurrence is re-hidden with a cancelled
|
||||
* exception, a modified one is re-inserted with its fields (then its reminders
|
||||
* reconciled and editable guests copied). No-op for a non-recurring event.
|
||||
* Carry every exception of the series [fromEventId] over to the moved series
|
||||
* [toEventId]. The moved row has no `_sync_id` yet, so exception rows would
|
||||
* never attach to it ([deleteOccurrence], [detachOccurrence]): a cancelled
|
||||
* occurrence becomes an EXDATE, a modified one an EXDATE plus a standalone
|
||||
* event on [targetCalendarId], its id added to [detached] for rollback.
|
||||
*/
|
||||
private fun copyExceptions(fromEventId: Long, toEventId: Long) {
|
||||
queryExceptionRows(fromEventId).forEach { ex ->
|
||||
if (ex.isCancelled) {
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, ex.originalInstanceMillis)
|
||||
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
|
||||
}
|
||||
resolver.insert(
|
||||
ContentUris.withAppendedId(
|
||||
CalendarContract.Events.CONTENT_EXCEPTION_URI, toEventId,
|
||||
),
|
||||
values,
|
||||
) ?: throw WriteFailedException("copy cancelled occurrence to event id=$toEventId")
|
||||
} else {
|
||||
val uri = resolver.insert(
|
||||
ContentUris.withAppendedId(
|
||||
CalendarContract.Events.CONTENT_EXCEPTION_URI, toEventId,
|
||||
),
|
||||
buildCopiedExceptionValues(ex).toContentValues(),
|
||||
) ?: throw WriteFailedException("copy modified occurrence to event id=$toEventId")
|
||||
val newExceptionId = ContentUris.parseId(uri)
|
||||
// The provider may clone the parent's reminders onto the new
|
||||
// exception; reconcile to the source exception's exact set so
|
||||
// they neither double nor drop. Same DTSTART → same all-day
|
||||
// encoding, so the stored offsets match directly.
|
||||
reconcileReminders(
|
||||
newExceptionId,
|
||||
queryReminders(ex.exceptionEventId).map { it.minutes },
|
||||
)
|
||||
insertAttendees(newExceptionId, editableAttendees(ex.exceptionEventId))
|
||||
}
|
||||
private fun copyExceptions(
|
||||
fromEventId: Long,
|
||||
toEventId: Long,
|
||||
targetCalendarId: Long,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
detached: MutableList<Long>,
|
||||
) {
|
||||
val exceptions = queryExceptionRows(fromEventId)
|
||||
if (exceptions.isEmpty()) return
|
||||
val zone = TimeZone.currentSystemDefault()
|
||||
exceptions.filterNot { it.isCancelled }.forEach { ex ->
|
||||
val detail = eventDetail(ex.exceptionEventId, allDayReminderTimeMinutes)
|
||||
?: 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)
|
||||
.toDetachedOccurrence()
|
||||
detached += insertEvent(form, allDayReminderTimeMinutes)
|
||||
}
|
||||
val values = querySeriesRow(toEventId).exdateValues(exceptions.map { it.originalInstanceMillis })
|
||||
updateEventRow(toEventId, values, "exdate moved exceptions event id=$toEventId")
|
||||
}
|
||||
|
||||
/** The series' exception rows (modified + cancelled), oldest occurrence first. */
|
||||
@@ -1210,26 +1132,14 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
arrayOf(seriesEventId.toString()),
|
||||
CalendarContract.Events.ORIGINAL_INSTANCE_TIME + " ASC",
|
||||
)?.use { c ->
|
||||
c.mapAll {
|
||||
val r = CursorColumnReader(c)
|
||||
val status = r.getInt(ExceptionProjection.IDX_STATUS)
|
||||
.takeUnless { r.isNull(ExceptionProjection.IDX_STATUS) }
|
||||
c.mapRows {
|
||||
ExceptionRowSnapshot(
|
||||
exceptionEventId = r.getLong(ExceptionProjection.IDX_ID),
|
||||
originalInstanceMillis = r.getLong(ExceptionProjection.IDX_ORIGINAL_INSTANCE_TIME),
|
||||
isCancelled = status == CalendarContract.Events.STATUS_CANCELED,
|
||||
status = status,
|
||||
title = r.getString(ExceptionProjection.IDX_TITLE).orEmpty(),
|
||||
isAllDay = r.getInt(ExceptionProjection.IDX_ALL_DAY) != 0,
|
||||
dtStartMillis = r.getLong(ExceptionProjection.IDX_DTSTART),
|
||||
dtEndMillis = r.getLong(ExceptionProjection.IDX_DTEND)
|
||||
.takeUnless { r.isNull(ExceptionProjection.IDX_DTEND) },
|
||||
duration = r.getString(ExceptionProjection.IDX_DURATION),
|
||||
timezone = r.getString(ExceptionProjection.IDX_EVENT_TIMEZONE),
|
||||
availability = r.getInt(ExceptionProjection.IDX_AVAILABILITY),
|
||||
accessLevel = r.getInt(ExceptionProjection.IDX_ACCESS_LEVEL),
|
||||
location = r.getString(ExceptionProjection.IDX_LOCATION),
|
||||
description = r.getString(ExceptionProjection.IDX_DESCRIPTION),
|
||||
exceptionEventId = getLong(ExceptionProjection.IDX_ID),
|
||||
originalInstanceMillis = getLong(ExceptionProjection.IDX_ORIGINAL_INSTANCE_TIME),
|
||||
isCancelled = getIntOrNull(ExceptionProjection.IDX_STATUS) == CalendarContract.Events.STATUS_CANCELED,
|
||||
dtStartMillis = getLong(ExceptionProjection.IDX_DTSTART),
|
||||
dtEndMillis = getLongOrNull(ExceptionProjection.IDX_DTEND),
|
||||
duration = getString(ExceptionProjection.IDX_DURATION),
|
||||
)
|
||||
}
|
||||
} ?: emptyList()
|
||||
@@ -1266,7 +1176,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
val exceptionId = ContentUris.parseId(uri)
|
||||
form.colorKey?.let { key ->
|
||||
resolver.update(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, exceptionId),
|
||||
eventUri(exceptionId),
|
||||
ContentValues().apply { put(CalendarContract.Events.EVENT_COLOR_KEY, key) },
|
||||
null, null,
|
||||
)
|
||||
@@ -1320,34 +1230,19 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
// a fresh UID — the detached row is a separate event now, and sharing the
|
||||
// parent's would collide with it in .ics restore dedup.
|
||||
val detachedId = insertEvent(form.toDetachedOccurrence(), allDayReminderTimeMinutes)
|
||||
val values = buildOccurrenceExdateValues(
|
||||
existingExdate = row.exdate,
|
||||
occurrenceMillis = beginMillis,
|
||||
dtStartMillis = row.dtStartMillis,
|
||||
rrule = row.rrule,
|
||||
rdate = row.rdate,
|
||||
duration = row.duration,
|
||||
timezone = row.timezone,
|
||||
allDay = row.allDay,
|
||||
)
|
||||
// Rows touched, not occurrences excluded — 1 whenever the series row still
|
||||
// exists. It catches the row disappearing under us, not an EXDATE the
|
||||
// provider's expansion fails to match.
|
||||
val updatedRows = try {
|
||||
resolver.update(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||
values.toContentValues(), null, null,
|
||||
try {
|
||||
updateEventRow(
|
||||
eventId,
|
||||
row.exdateValues(listOf(beginMillis)),
|
||||
"exdate occurrence for edit, event id=$eventId begin=$beginMillis",
|
||||
)
|
||||
} catch (t: Throwable) {
|
||||
rollBackDetached(detachedId)
|
||||
throw t
|
||||
}
|
||||
if (updatedRows == 0) {
|
||||
rollBackDetached(detachedId)
|
||||
throw WriteFailedException(
|
||||
"exdate occurrence for edit, event id=$eventId begin=$beginMillis",
|
||||
)
|
||||
}
|
||||
return detachedId
|
||||
}
|
||||
|
||||
@@ -1420,23 +1315,11 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
updated = updated,
|
||||
zone = ZoneId.systemDefault(),
|
||||
) ?: return
|
||||
val row = querySeriesRow(newEventId)
|
||||
val values = ContentValues().apply {
|
||||
val values = querySeriesRow(newEventId).timeColumns().apply {
|
||||
put(CalendarContract.Events.EXDATE, carried)
|
||||
put(CalendarContract.Events.DTSTART, row.dtStartMillis)
|
||||
put(CalendarContract.Events.RRULE, row.rrule)
|
||||
put(CalendarContract.Events.DURATION, row.duration)
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, row.timezone)
|
||||
put(CalendarContract.Events.ALL_DAY, row.allDay)
|
||||
}
|
||||
try {
|
||||
val rows = resolver.update(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, newEventId),
|
||||
values, null, null,
|
||||
)
|
||||
if (rows == 0) {
|
||||
throw WriteFailedException("carry exdate onto split series id=$newEventId")
|
||||
}
|
||||
updateEventRow(newEventId, values, "carry exdate onto split series id=$newEventId")
|
||||
} catch (t: Throwable) {
|
||||
runCatching { deleteEvent(newEventId) }
|
||||
throw t
|
||||
@@ -1464,29 +1347,18 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
*/
|
||||
private fun truncateSeries(eventId: Long, row: SeriesRow, beginMillis: Long) {
|
||||
requireNotNull(row.rrule) { "truncateSeries needs a recurring row" }
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Events.DTSTART, row.dtStartMillis)
|
||||
put(CalendarContract.Events.DURATION, row.duration)
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, row.timezone)
|
||||
put(CalendarContract.Events.ALL_DAY, row.allDay)
|
||||
val values = row.timeColumns().apply {
|
||||
put(
|
||||
CalendarContract.Events.RRULE,
|
||||
rruleTruncatedAt(row.rrule, untilUtcMillis = row.truncationCutoff(beginMillis)),
|
||||
)
|
||||
}
|
||||
val rows = resolver.update(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||
values,
|
||||
null, null,
|
||||
)
|
||||
if (rows == 0) {
|
||||
throw WriteFailedException("truncate series event id=$eventId begin=$beginMillis")
|
||||
}
|
||||
updateEventRow(eventId, values, "truncate series event id=$eventId begin=$beginMillis")
|
||||
}
|
||||
|
||||
/** The series anchor: every time-related column of the Events row. */
|
||||
private fun querySeriesRow(eventId: Long): SeriesRow = resolver.query(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||
eventUri(eventId),
|
||||
arrayOf(
|
||||
CalendarContract.Events.DTSTART,
|
||||
CalendarContract.Events.RRULE,
|
||||
@@ -1534,6 +1406,30 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
*/
|
||||
val recurs: Boolean get() = !rrule.isNullOrBlank() || !rdate.isNullOrBlank()
|
||||
|
||||
/**
|
||||
* The time/recurrence set a series write carries together: the provider
|
||||
* re-expands instances only from what the update itself holds.
|
||||
*/
|
||||
fun timeColumns(): ContentValues = ContentValues().apply {
|
||||
put(CalendarContract.Events.DTSTART, dtStartMillis)
|
||||
put(CalendarContract.Events.RRULE, rrule)
|
||||
put(CalendarContract.Events.DURATION, duration)
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, timezone)
|
||||
put(CalendarContract.Events.ALL_DAY, allDay)
|
||||
}
|
||||
|
||||
/** This row's columns with [occurrences] added to EXDATE ([buildOccurrencesExdateValues]). */
|
||||
fun exdateValues(occurrences: List<Long>): Map<String, Any?> = buildOccurrencesExdateValues(
|
||||
existingExdate = exdate,
|
||||
occurrences = occurrences,
|
||||
dtStartMillis = dtStartMillis,
|
||||
rrule = rrule,
|
||||
rdate = rdate,
|
||||
duration = duration,
|
||||
timezone = timezone,
|
||||
allDay = allDay,
|
||||
)
|
||||
|
||||
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
|
||||
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
|
||||
beginMillis = beginMillis,
|
||||
@@ -1559,20 +1455,10 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
arrayOf(eventId.toString(), minutes.toString()),
|
||||
)
|
||||
}
|
||||
(target - existing).forEach { minutes ->
|
||||
val reminder = ContentValues().apply {
|
||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||
}
|
||||
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
||||
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
||||
}
|
||||
}
|
||||
insertReminderRows(eventId, (target - existing).toList())
|
||||
}
|
||||
|
||||
/** Normalised key an attendee is matched/deduped on (case-insensitive email). */
|
||||
private fun EventAttendee.key(): String = email.trim().lowercase()
|
||||
private fun EventAttendee.key(): String = emailKey(email)
|
||||
|
||||
/** Distinct guests by email; blanks dropped (we key and write on the address). */
|
||||
private fun List<EventAttendee>.distinctByEmail(): List<EventAttendee> {
|
||||
@@ -1624,7 +1510,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
val relationship: Int,
|
||||
val type: Int,
|
||||
) {
|
||||
val key: String get() = email.trim().lowercase()
|
||||
val key: String get() = emailKey(email)
|
||||
}
|
||||
|
||||
private fun queryAttendeeRows(eventId: Long): List<AttendeeRow> = resolver.query(
|
||||
@@ -1689,17 +1575,20 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
)
|
||||
}
|
||||
}
|
||||
target.filterNot { it.key() in editableKeys }.forEach { attendee ->
|
||||
if (resolver.insert(
|
||||
CalendarContract.Attendees.CONTENT_URI,
|
||||
attendeeValues(eventId, attendee),
|
||||
) == null
|
||||
) {
|
||||
Log.w(TAG, "Failed to attach a guest to event $eventId")
|
||||
}
|
||||
}
|
||||
insertAttendees(eventId, target.filterNot { it.key() in editableKeys })
|
||||
}
|
||||
|
||||
private fun eventUri(eventId: Long): Uri =
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId)
|
||||
|
||||
/** Update the Events row [eventId]; [what] names the write when no row was touched. */
|
||||
private fun updateEventRow(eventId: Long, values: ContentValues, what: String) {
|
||||
if (resolver.update(eventUri(eventId), values, null, null) == 0) throw WriteFailedException(what)
|
||||
}
|
||||
|
||||
private fun updateEventRow(eventId: Long, values: Map<String, Any?>, what: String) =
|
||||
updateEventRow(eventId, values.toContentValues(), what)
|
||||
|
||||
private fun Map<String, Any?>.toContentValues(): ContentValues =
|
||||
ContentValues().also { cv ->
|
||||
forEach { (column, value) ->
|
||||
@@ -1716,7 +1605,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
|
||||
override fun deleteEvent(eventId: Long) {
|
||||
val deleted = resolver.delete(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||
eventUri(eventId),
|
||||
null, null,
|
||||
)
|
||||
if (deleted == 0) throw WriteFailedException("delete event id=$eventId")
|
||||
@@ -1738,23 +1627,11 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
// occurrence with it (#47 on a local calendar). EXDATE needs no link.
|
||||
// Calendula's own contact special-date calendars are local and hold
|
||||
// yearly series, so this path is reached in normal use.
|
||||
val values = buildOccurrenceExdateValues(
|
||||
existingExdate = row.exdate,
|
||||
occurrenceMillis = beginMillis,
|
||||
dtStartMillis = row.dtStartMillis,
|
||||
rrule = row.rrule,
|
||||
rdate = row.rdate,
|
||||
duration = row.duration,
|
||||
timezone = row.timezone,
|
||||
allDay = row.allDay,
|
||||
updateEventRow(
|
||||
eventId,
|
||||
row.exdateValues(listOf(beginMillis)),
|
||||
"exdate occurrence event id=$eventId begin=$beginMillis",
|
||||
)
|
||||
val updated = resolver.update(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||
values.toContentValues(), null, null,
|
||||
)
|
||||
if (updated == 0) {
|
||||
throw WriteFailedException("exdate occurrence event id=$eventId begin=$beginMillis")
|
||||
}
|
||||
return
|
||||
}
|
||||
// A cancelled exception row hides exactly this occurrence; the sync
|
||||
@@ -1804,7 +1681,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
CalendarContract.Attendees.EVENT_ID + " = ?",
|
||||
arrayOf(eventId.toString()),
|
||||
null,
|
||||
)?.use { c -> c.mapAll { CursorColumnReader(c).toAttendee() } } ?: emptyList()
|
||||
)?.use { c -> c.mapRows { toAttendee() } } ?: emptyList()
|
||||
|
||||
private fun queryReminders(eventId: Long): List<Reminder> = resolver.query(
|
||||
CalendarContract.Reminders.CONTENT_URI,
|
||||
@@ -1812,19 +1689,15 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
CalendarContract.Reminders.EVENT_ID + " = ?",
|
||||
arrayOf(eventId.toString()),
|
||||
null,
|
||||
)?.use { c -> c.mapAll { CursorColumnReader(c).toReminder() } } ?: emptyList()
|
||||
|
||||
private fun toCalendarSource(c: Cursor): CalendarSource = CursorColumnReader(c).toCalendarSource()
|
||||
)?.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))
|
||||
}
|
||||
|
||||
/** Iterate every row and map; drops nulls. */
|
||||
private inline fun <T : Any> Cursor.mapAllNotNull(mapper: (Cursor) -> T?): List<T> = buildList {
|
||||
while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add)
|
||||
}
|
||||
/** A fresh globally-unique `UID_2445`, for an event that carries none of its own. */
|
||||
private fun mintUid(): String = "${UUID.randomUUID()}@calendula"
|
||||
|
||||
/** The `CAL_SYNC2` marker value identifying a managed calendar's [type]. */
|
||||
private fun markerFor(type: SpecialDateType): String = when (type) {
|
||||
@@ -1840,6 +1713,9 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
private companion object {
|
||||
const val TAG = "CalendarDataSource"
|
||||
|
||||
/** Normalised key an attendee is matched/deduped on (case-insensitive email). */
|
||||
fun emailKey(email: String): String = email.trim().lowercase()
|
||||
|
||||
/**
|
||||
* Shared account for every app-created local calendar, so they group
|
||||
* together (by account) in the filter sheet and calendar manager.
|
||||
|
||||
@@ -29,7 +29,7 @@ interface CalendarRepository {
|
||||
|
||||
/**
|
||||
* The event-colour palette a calendar's account publishes; empty when it
|
||||
* exposes none (see [CalendarDataSource.eventColorPalette]).
|
||||
* exposes none (see [CalendarDataSource.publishedEventColors]).
|
||||
*/
|
||||
suspend fun eventColorPalette(calendarId: Long): List<EventColorOption>
|
||||
|
||||
|
||||
+16
-15
@@ -1,10 +1,11 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.util.Log
|
||||
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||
import de.jeanlucmakiola.floret.time.toEpochMillis
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.curatedForPicker
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
@@ -125,8 +126,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
beginMillis = range.start.toEpochMillis(),
|
||||
endMillis = range.endInclusive.toEpochMillis(),
|
||||
),
|
||||
switchedOffCalendarIds = invisibleCalendarIds() +
|
||||
prefs.pendingDisabledCalendarIds.first(),
|
||||
switchedOffCalendarIds = switchedOffCalendarIds(),
|
||||
)
|
||||
},
|
||||
prefs.hiddenCalendarIds,
|
||||
@@ -151,6 +151,10 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
.filterNot { it.isVisibleInSystem }
|
||||
.mapTo(mutableSetOf()) { it.id }
|
||||
|
||||
/** [invisibleCalendarIds] plus the switch-offs still waiting to be written. */
|
||||
private suspend fun switchedOffCalendarIds(): Set<Long> =
|
||||
invisibleCalendarIds() + prefs.pendingDisabledCalendarIds.first()
|
||||
|
||||
private val calendarsLock = Mutex()
|
||||
private var cachedGeneration = -1L
|
||||
private var cachedPending: Set<Long>? = null
|
||||
@@ -184,15 +188,13 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
|
||||
override suspend fun searchEvents(query: String): List<SearchCandidate> = withContext(io) {
|
||||
if (query.isBlank()) return@withContext emptyList()
|
||||
val excluded = prefs.hiddenCalendarIds.first() +
|
||||
prefs.pendingDisabledCalendarIds.first() +
|
||||
invisibleCalendarIds()
|
||||
val excluded = prefs.hiddenCalendarIds.first() + switchedOffCalendarIds()
|
||||
dataSource.searchEvents(query)
|
||||
.let { if (excluded.isEmpty()) it else it.filterNot { c -> c.event.calendarId in excluded } }
|
||||
}
|
||||
|
||||
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||
withContext(io) { dataSource.eventColorPalette(calendarId) }
|
||||
withContext(io) { dataSource.publishedEventColors(calendarId).curatedForPicker() }
|
||||
|
||||
override suspend fun createLocalCalendar(
|
||||
displayName: String,
|
||||
@@ -214,16 +216,15 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
|
||||
override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) =
|
||||
write {
|
||||
if (dataSource.canWriteCalendars()) {
|
||||
ids.forEach { dataSource.setCalendarVisible(it, visible) }
|
||||
// Nothing of ours is left waiting for the provider once the
|
||||
// write lands (and switching one back on retires its entry).
|
||||
prefs.removePendingDisabledCalendarIds(ids)
|
||||
} else if (visible) {
|
||||
val canWrite = dataSource.canWriteCalendars()
|
||||
if (canWrite) ids.forEach { dataSource.setCalendarVisible(it, visible) }
|
||||
// Nothing of ours is left waiting for the provider once the write
|
||||
// lands, and switching one back on retires its entry. With read-only
|
||||
// permission a switch-off still works, app-side, and the reconciler
|
||||
// flushes it if WRITE_CALENDAR ever arrives.
|
||||
if (canWrite || visible) {
|
||||
prefs.removePendingDisabledCalendarIds(ids)
|
||||
} else {
|
||||
// Read-only permission: the switch still works, app-side, and
|
||||
// the reconciler flushes it if WRITE_CALENDAR ever arrives.
|
||||
prefs.addPendingDisabledCalendarIds(ids)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-7
@@ -4,15 +4,15 @@ import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.calendarVisibilityPlan
|
||||
import de.jeanlucmakiola.calendula.domain.hasSystemHiddenCalendars
|
||||
import de.jeanlucmakiola.floret.android.hasPermission
|
||||
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -47,7 +47,7 @@ class CalendarVisibilityReconciler @Inject constructor(
|
||||
// Settled ahead of the permission gate, so an update installed
|
||||
// before the first grant can't later look like an upgrade.
|
||||
if (!isUpgradeInstall()) settleNoticeOnce(pending = false)
|
||||
if (!hasPermission(Manifest.permission.READ_CALENDAR)) return@withContext
|
||||
if (!context.hasPermission(Manifest.permission.READ_CALENDAR)) return@withContext
|
||||
val pending = prefs.pendingDisabledCalendarIds.first()
|
||||
val noticeSettled = prefs.visibilityNoticePending.first() != null
|
||||
if (pending.isEmpty() && noticeSettled) return@withContext
|
||||
@@ -56,7 +56,7 @@ class CalendarVisibilityReconciler @Inject constructor(
|
||||
// Both decisions below are one-way, so leave them to the next run.
|
||||
if (calendars.isEmpty()) return@withContext
|
||||
settleNoticeOnce(hasSystemHiddenCalendars(calendars, pending))
|
||||
if (pending.isEmpty() || !hasPermission(Manifest.permission.WRITE_CALENDAR)) {
|
||||
if (pending.isEmpty() || !context.hasPermission(Manifest.permission.WRITE_CALENDAR)) {
|
||||
return@withContext
|
||||
}
|
||||
val plan = calendarVisibilityPlan(calendars, pending)
|
||||
@@ -97,9 +97,6 @@ class CalendarVisibilityReconciler @Inject constructor(
|
||||
false
|
||||
}
|
||||
|
||||
private fun hasPermission(permission: String): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/** Lets non-injectable entry points (the Application) reach the reconciler. */
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.database.Cursor
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneOffset
|
||||
|
||||
/**
|
||||
* Read-only view over a single row's columns by index. Lets the mappers work
|
||||
@@ -20,3 +25,33 @@ internal class CursorColumnReader(private val cursor: Cursor) : ColumnReader {
|
||||
override fun getInt(index: Int): Int = cursor.getInt(index)
|
||||
override fun isNull(index: Int): Boolean = cursor.isNull(index)
|
||||
}
|
||||
|
||||
internal fun ColumnReader.getIntOrNull(index: Int): Int? = if (isNull(index)) null else getInt(index)
|
||||
|
||||
internal fun ColumnReader.getLongOrNull(index: Int): Long? = if (isNull(index)) null else getLong(index)
|
||||
|
||||
/**
|
||||
* The row's end: DTEND, or DTSTART + DURATION for a recurring row, which stores
|
||||
* no DTEND. Unclamped; a missing/blank both means a zero-length event.
|
||||
*/
|
||||
internal fun ColumnReader.endMillis(dtStartMillis: Long, dtEndIndex: Int, durationIndex: Int): Long =
|
||||
getLongOrNull(dtEndIndex) ?: (dtStartMillis + parseRfc2445DurationMillis(getString(durationIndex)))
|
||||
|
||||
/** STATUS shares value 0 with TENTATIVE, so an absent column must read as Confirmed. */
|
||||
internal fun ColumnReader.eventStatus(index: Int): EventStatus =
|
||||
getIntOrNull(index)?.let(::mapEventStatus) ?: EventStatus.Confirmed
|
||||
|
||||
/** Map every row, reading each through one [CursorColumnReader]. */
|
||||
internal inline fun <T> Cursor.mapRows(map: ColumnReader.() -> T): List<T> {
|
||||
val reader = CursorColumnReader(this)
|
||||
return buildList { while (moveToNext()) add(reader.map()) }
|
||||
}
|
||||
|
||||
/** [mapRows], dropping the rows [map] returns null for. */
|
||||
internal inline fun <T : Any> Cursor.mapRowsNotNull(map: ColumnReader.() -> T?): List<T> {
|
||||
val reader = CursorColumnReader(this)
|
||||
return buildList { while (moveToNext()) reader.map()?.let(::add) }
|
||||
}
|
||||
|
||||
/** The UTC calendar day of [millis] — an all-day row's date, since it sits at UTC midnight. */
|
||||
internal fun utcDate(millis: Long): LocalDate = Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||
|
||||
@@ -15,9 +15,7 @@ import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
|
||||
private const val TAG = "EventDetailMapper"
|
||||
|
||||
@@ -48,11 +46,8 @@ internal fun ColumnReader.toEventDetailCore(
|
||||
// — the same trap as the pre-1970 DTSTART bug above (issue #34): it would
|
||||
// surface as the generic error screen with no way to open the event and fix
|
||||
// it. Clamp to a zero-length event instead (matching SearchMapper).
|
||||
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
|
||||
begin + parseRfc2445DurationMillis(getString(EventDetailProjection.IDX_DURATION))
|
||||
} else {
|
||||
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
|
||||
}
|
||||
val end = getLongOrNull(EventDetailProjection.IDX_DTEND)?.coerceAtLeast(begin)
|
||||
?: (begin + parseRfc2445DurationMillis(getString(EventDetailProjection.IDX_DURATION)))
|
||||
|
||||
// Kept raw (no untitled fallback): the detail screen substitutes its own
|
||||
// localized placeholder, and the edit form must prefill the true value.
|
||||
@@ -61,11 +56,7 @@ internal fun ColumnReader.toEventDetailCore(
|
||||
// The event's own colour (null = inherits the calendar's) is kept apart
|
||||
// from the resolved display colour: the edit form needs to tell the two
|
||||
// cases apart, while the instance carries the calendar fallback for display.
|
||||
val eventColor = if (isNull(EventDetailProjection.IDX_EVENT_COLOR)) {
|
||||
null
|
||||
} else {
|
||||
getInt(EventDetailProjection.IDX_EVENT_COLOR)
|
||||
}
|
||||
val eventColor = getIntOrNull(EventDetailProjection.IDX_EVENT_COLOR)
|
||||
val eventColorKey = getString(EventDetailProjection.IDX_EVENT_COLOR_KEY)
|
||||
val color = eventColor ?: getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
|
||||
|
||||
@@ -88,7 +79,7 @@ internal fun ColumnReader.toEventDetailCore(
|
||||
// and detail screen speak. DTSTART is UTC midnight for all-day events, so the
|
||||
// event's date is its UTC date.
|
||||
val displayReminders = if (isAllDay) {
|
||||
val startDate = Instant.ofEpochMilli(begin).atZone(ZoneOffset.UTC).toLocalDate()
|
||||
val startDate = utcDate(begin)
|
||||
val zone = ZoneId.systemDefault()
|
||||
reminders.map {
|
||||
it.copy(
|
||||
@@ -101,14 +92,6 @@ internal fun ColumnReader.toEventDetailCore(
|
||||
reminders
|
||||
}
|
||||
|
||||
// STATUS shares its 0 value with STATUS_TENTATIVE, so a missing column must
|
||||
// be distinguished from a present 0 — an absent status means "just confirmed".
|
||||
val status = if (isNull(EventDetailProjection.IDX_STATUS)) {
|
||||
EventStatus.Confirmed
|
||||
} else {
|
||||
mapEventStatus(getInt(EventDetailProjection.IDX_STATUS))
|
||||
}
|
||||
|
||||
return EventDetail(
|
||||
instance = instance,
|
||||
description = getString(EventDetailProjection.IDX_DESCRIPTION),
|
||||
@@ -116,7 +99,7 @@ internal fun ColumnReader.toEventDetailCore(
|
||||
attendees = attendees,
|
||||
rrule = getString(EventDetailProjection.IDX_RRULE),
|
||||
reminders = displayReminders,
|
||||
status = status,
|
||||
status = eventStatus(EventDetailProjection.IDX_STATUS),
|
||||
// BUSY and DEFAULT are both 0, so a null column folds into the same
|
||||
// default these mappers already return — no isNull guard needed.
|
||||
availability = mapAvailability(getInt(EventDetailProjection.IDX_AVAILABILITY)),
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.instantIn
|
||||
import kotlinx.datetime.toKotlinTimeZone
|
||||
import android.provider.CalendarContract
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.FormTime
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.nearestTo
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.toJavaLocalDateTime
|
||||
import kotlinx.datetime.toJavaZoneOffset
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
@@ -48,8 +52,8 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
|
||||
} else {
|
||||
val writeZone = writeZone(zone)
|
||||
EventWriteTimes(
|
||||
dtStartMillis = start.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
|
||||
dtEndMillis = end.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
|
||||
dtStartMillis = start.instantIn(writeZone.toKotlinTimeZone()).toEpochMilliseconds(),
|
||||
dtEndMillis = end.instantIn(writeZone.toKotlinTimeZone()).toEpochMilliseconds(),
|
||||
timezone = writeZone.id,
|
||||
)
|
||||
}
|
||||
@@ -75,7 +79,7 @@ private fun EventForm.writeZone(deviceZone: ZoneId): ZoneId = if (isAllDay) {
|
||||
private fun EventForm.anchorLocal(): JavaLocalDateTime = if (isAllDay) {
|
||||
start.date.toJavaLocalDate().atStartOfDay()
|
||||
} else {
|
||||
start.toJavaLocalDateTime()
|
||||
start.dateTime.toJavaLocalDateTime()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -181,7 +185,8 @@ internal fun buildEventUpdateValues(
|
||||
|
||||
// A zone change counts as a time change even when the wall-clock is
|
||||
// untouched: the same 09:00 in another zone is a different instant, so
|
||||
// DTSTART has to move with it.
|
||||
// DTSTART has to move with it. So does a switch between the two passes of
|
||||
// the fall-back hour, which each [FormTime] carries (#387).
|
||||
val timesChanged = updated.start != original.start ||
|
||||
updated.end != original.end ||
|
||||
updated.isAllDay != original.isAllDay ||
|
||||
@@ -209,15 +214,23 @@ internal fun buildEventUpdateValues(
|
||||
// is a different offset from the series anchor's whenever a DST boundary
|
||||
// sits between them, or whenever the zone itself changed. Working in wall
|
||||
// clock keeps "09:00" meaning 09:00 at both ends.
|
||||
val seriesLocal = Instant.ofEpochMilli(seriesDtStartMillis)
|
||||
.atZone(original.writeZone(zone)).toLocalDateTime()
|
||||
val seriesZoned = Instant.ofEpochMilli(seriesDtStartMillis).atZone(original.writeZone(zone))
|
||||
val seriesLocal = seriesZoned.toLocalDateTime()
|
||||
val wallClockShift = Duration.between(original.anchorLocal(), updated.anchorLocal())
|
||||
val shifted = seriesLocal.plus(wallClockShift)
|
||||
// An all-day series anchor must sit on a UTC midnight. A pure day move
|
||||
// already lands there (both ends are midnights), but *switching* a
|
||||
// recurring event to all-day shifts by a time-of-day too, so snap.
|
||||
val newSeriesLocal = if (updated.isAllDay) shifted.toLocalDate().atStartOfDay() else shifted
|
||||
val newSeriesStart = newSeriesLocal.atZone(updated.writeZone(zone))
|
||||
// A wall clock the fall-back hour repeats keeps the anchor's own pass, or,
|
||||
// when the anchor is the edited occurrence itself, the pass the form now
|
||||
// holds: a bare wall clock would always take the earlier one (#387).
|
||||
val pass = if (seriesLocal == original.anchorLocal()) {
|
||||
updated.start.offset?.toJavaZoneOffset()
|
||||
} else {
|
||||
seriesZoned.offset
|
||||
}
|
||||
val newSeriesStart = ZonedDateTime.ofLocal(newSeriesLocal, updated.writeZone(zone), pass)
|
||||
put(CalendarContract.Events.DTSTART, newSeriesStart.toInstant().toEpochMilli())
|
||||
put(CalendarContract.Events.DTEND, null)
|
||||
put(CalendarContract.Events.RRULE, updated.rrule)
|
||||
@@ -359,48 +372,11 @@ internal data class ExceptionRowSnapshot(
|
||||
val exceptionEventId: Long,
|
||||
val originalInstanceMillis: Long,
|
||||
val isCancelled: Boolean,
|
||||
val status: Int?,
|
||||
val title: String,
|
||||
val isAllDay: Boolean,
|
||||
val dtStartMillis: Long,
|
||||
val dtEndMillis: Long?,
|
||||
val duration: String?,
|
||||
val timezone: String?,
|
||||
val availability: Int,
|
||||
val accessLevel: Int,
|
||||
val location: String?,
|
||||
val description: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Column values replaying a *modified* occurrence [snapshot] against the moved
|
||||
* series via `CONTENT_EXCEPTION_URI`. Like [buildOccurrenceExceptionValues] the
|
||||
* length travels as DURATION (the provider rejects DTEND on an exception). A
|
||||
* cancelled occurrence is written separately (ORIGINAL_INSTANCE_TIME +
|
||||
* STATUS_CANCELED) — this builder is only for the modified case.
|
||||
*/
|
||||
internal fun buildCopiedExceptionValues(snapshot: ExceptionRowSnapshot): Map<String, Any?> =
|
||||
buildMap {
|
||||
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, snapshot.originalInstanceMillis)
|
||||
put(CalendarContract.Events.TITLE, snapshot.title)
|
||||
put(CalendarContract.Events.ALL_DAY, if (snapshot.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.DTSTART, snapshot.dtStartMillis)
|
||||
put(
|
||||
CalendarContract.Events.DURATION,
|
||||
snapshot.duration?.takeIf { it.isNotBlank() }
|
||||
?: rfc2445Duration(
|
||||
(snapshot.dtEndMillis ?: snapshot.dtStartMillis) - snapshot.dtStartMillis,
|
||||
snapshot.isAllDay,
|
||||
),
|
||||
)
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, snapshot.timezone ?: "UTC")
|
||||
put(CalendarContract.Events.AVAILABILITY, snapshot.availability)
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel)
|
||||
put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null })
|
||||
put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null })
|
||||
snapshot.status?.let { put(CalendarContract.Events.STATUS, it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Column values for a *cancelled*-occurrence exception row ("delete only this
|
||||
* event"): inserting them at `Events.CONTENT_EXCEPTION_URI/<id>` makes the
|
||||
@@ -466,9 +442,24 @@ internal fun buildOccurrenceExdateValues(
|
||||
duration: String?,
|
||||
timezone: String?,
|
||||
allDay: Int,
|
||||
): Map<String, Any?> = buildOccurrencesExdateValues(
|
||||
existingExdate, listOf(occurrenceMillis), dtStartMillis, rrule, rdate, duration, timezone, allDay,
|
||||
)
|
||||
|
||||
/** [buildOccurrenceExdateValues] excluding every one of [occurrences] in one write. */
|
||||
internal fun buildOccurrencesExdateValues(
|
||||
existingExdate: String?,
|
||||
occurrences: List<Long>,
|
||||
dtStartMillis: Long,
|
||||
rrule: String?,
|
||||
rdate: String?,
|
||||
duration: String?,
|
||||
timezone: String?,
|
||||
allDay: Int,
|
||||
): Map<String, Any?> = mapOf(
|
||||
CalendarContract.Events.EXDATE to
|
||||
mergedExdate(existingExdate, occurrenceMillis, allDay != 0, timezone),
|
||||
CalendarContract.Events.EXDATE to occurrences.fold(existingExdate) { exdate, millis ->
|
||||
mergedExdate(exdate, millis, allDay != 0, timezone)
|
||||
},
|
||||
CalendarContract.Events.DTSTART to dtStartMillis,
|
||||
CalendarContract.Events.RRULE to rrule,
|
||||
CalendarContract.Events.RDATE to rdate,
|
||||
@@ -618,7 +609,7 @@ internal fun exdateAfter(
|
||||
* optionally behind the parameter prefix AOSP's `RecurrenceSet` puts in front of
|
||||
* a zoned one ([exdateListZone] reads that half).
|
||||
*/
|
||||
private fun exdateStamps(exdate: String?): List<String> = exdate
|
||||
internal fun exdateStamps(exdate: String?): List<String> = exdate
|
||||
?.withoutExdateParameters()
|
||||
?.split(',')
|
||||
?.map { it.trim() }
|
||||
@@ -888,7 +879,7 @@ internal fun buildImportedEventValues(
|
||||
*/
|
||||
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
|
||||
val span = (endMillis - startMillis).coerceAtLeast(0)
|
||||
return if (isAllDay) "P${(span / 86_400_000L).coerceAtLeast(1)}D" else "P${span / 1_000L}S"
|
||||
return rfc2445Duration(if (isAllDay) span.coerceAtLeast(MILLIS_PER_DAY) else span, isAllDay)
|
||||
}
|
||||
|
||||
private fun EventStatus.toProviderStatus(): Int = when (this) {
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
|
||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
|
||||
/**
|
||||
* Map one Events row (read through [EventExportProjection]) into an [IcsEvent]
|
||||
@@ -33,19 +29,8 @@ internal fun ColumnReader.toIcsEvent(
|
||||
val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }
|
||||
val isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0
|
||||
|
||||
// Recurring rows store DURATION instead of DTEND; reconstruct the end from it
|
||||
// so the writer can render DTEND. A missing/blank both means a zero-length event.
|
||||
val end = when {
|
||||
!isNull(EventExportProjection.IDX_DTEND) -> getLong(EventExportProjection.IDX_DTEND)
|
||||
else -> dtStart + parseRfc2445DurationMillis(getString(EventExportProjection.IDX_DURATION))
|
||||
}
|
||||
|
||||
// STATUS shares value 0 with TENTATIVE, so an absent column must read as Confirmed.
|
||||
val status = if (isNull(EventExportProjection.IDX_STATUS)) {
|
||||
EventStatus.Confirmed
|
||||
} else {
|
||||
mapEventStatus(getInt(EventExportProjection.IDX_STATUS))
|
||||
}
|
||||
// Unclamped: the writer renders DTEND as stored.
|
||||
val end = endMillis(dtStart, EventExportProjection.IDX_DTEND, EventExportProjection.IDX_DURATION)
|
||||
|
||||
return IcsEvent(
|
||||
uid = deriveIcsUid(getString(EventExportProjection.IDX_UID), eventId, dtStart),
|
||||
@@ -68,14 +53,14 @@ internal fun ColumnReader.toIcsEvent(
|
||||
location = getString(EventExportProjection.IDX_LOCATION),
|
||||
description = getString(EventExportProjection.IDX_DESCRIPTION),
|
||||
reminderMinutes = if (isAllDay) {
|
||||
val startDate = Instant.ofEpochMilli(dtStart).atZone(ZoneOffset.UTC).toLocalDate()
|
||||
val startDate = utcDate(dtStart)
|
||||
reminderMinutes.map {
|
||||
fromProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes)
|
||||
}
|
||||
} else {
|
||||
reminderMinutes
|
||||
},
|
||||
status = status,
|
||||
status = eventStatus(EventExportProjection.IDX_STATUS),
|
||||
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
|
||||
calendarName = calendarName,
|
||||
)
|
||||
@@ -112,11 +97,7 @@ internal fun exportExDates(
|
||||
): List<String> {
|
||||
if (exdate.isNullOrBlank()) return emptyList()
|
||||
val zone = exdateListZone(exdate, eventTimezone)
|
||||
return exdate
|
||||
.withoutExdateParameters()
|
||||
.split(',')
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
return exdateStamps(exdate)
|
||||
.map { stamp ->
|
||||
if (isAllDay) {
|
||||
stamp.substringBefore('T')
|
||||
|
||||
@@ -25,11 +25,7 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
|
||||
// string, so it belongs to the UI layer where it can be localised (#321).
|
||||
val title = getString(InstanceProjection.IDX_TITLE).orEmpty()
|
||||
|
||||
val color = if (isNull(InstanceProjection.IDX_EVENT_COLOR)) {
|
||||
getInt(InstanceProjection.IDX_CALENDAR_COLOR)
|
||||
} else {
|
||||
getInt(InstanceProjection.IDX_EVENT_COLOR)
|
||||
}
|
||||
val color = getIntOrNull(InstanceProjection.IDX_EVENT_COLOR) ?: getInt(InstanceProjection.IDX_CALENDAR_COLOR)
|
||||
|
||||
return EventInstance(
|
||||
instanceId = getLong(InstanceProjection.IDX_INSTANCE_ID),
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.net.Uri
|
||||
import android.provider.CalendarContract
|
||||
|
||||
/** The `Instances` table over the window [beginMillis]..[endMillis]. */
|
||||
internal fun instancesUri(beginMillis: Long, endMillis: Long): Uri =
|
||||
CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
||||
ContentUris.appendId(this, beginMillis)
|
||||
ContentUris.appendId(this, endMillis)
|
||||
}.build()
|
||||
|
||||
internal object CalendarProjection {
|
||||
val COLUMNS: Array<String> = arrayOf(
|
||||
CalendarContract.Calendars._ID,
|
||||
@@ -251,40 +260,26 @@ internal object MoveMasterProjection {
|
||||
|
||||
/**
|
||||
* The exception rows of a recurring series (`ORIGINAL_ID` = the series), read to
|
||||
* replay them against a moved copy (see [ExceptionRowSnapshot]). Both modified
|
||||
* carry them over to a moved copy (see [ExceptionRowSnapshot]). Both modified
|
||||
* occurrences and cancellations (`STATUS_CANCELED`) are read; the query filters
|
||||
* `DELETED = 0` so provider tombstones aren't replayed.
|
||||
* `DELETED = 0` so provider tombstones aren't carried over.
|
||||
*/
|
||||
internal object ExceptionProjection {
|
||||
val COLUMNS: Array<String> = arrayOf(
|
||||
CalendarContract.Events._ID,
|
||||
CalendarContract.Events.ORIGINAL_INSTANCE_TIME,
|
||||
CalendarContract.Events.STATUS,
|
||||
CalendarContract.Events.TITLE,
|
||||
CalendarContract.Events.ALL_DAY,
|
||||
CalendarContract.Events.DTSTART,
|
||||
CalendarContract.Events.DTEND,
|
||||
CalendarContract.Events.DURATION,
|
||||
CalendarContract.Events.EVENT_TIMEZONE,
|
||||
CalendarContract.Events.AVAILABILITY,
|
||||
CalendarContract.Events.ACCESS_LEVEL,
|
||||
CalendarContract.Events.EVENT_LOCATION,
|
||||
CalendarContract.Events.DESCRIPTION,
|
||||
)
|
||||
|
||||
const val IDX_ID = 0
|
||||
const val IDX_ORIGINAL_INSTANCE_TIME = 1
|
||||
const val IDX_STATUS = 2
|
||||
const val IDX_TITLE = 3
|
||||
const val IDX_ALL_DAY = 4
|
||||
const val IDX_DTSTART = 5
|
||||
const val IDX_DTEND = 6
|
||||
const val IDX_DURATION = 7
|
||||
const val IDX_EVENT_TIMEZONE = 8
|
||||
const val IDX_AVAILABILITY = 9
|
||||
const val IDX_ACCESS_LEVEL = 10
|
||||
const val IDX_LOCATION = 11
|
||||
const val IDX_DESCRIPTION = 12
|
||||
const val IDX_DTSTART = 3
|
||||
const val IDX_DTEND = 4
|
||||
const val IDX_DURATION = 5
|
||||
}
|
||||
|
||||
internal object AttendeeProjection {
|
||||
|
||||
@@ -2,7 +2,6 @@ package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||
|
||||
/**
|
||||
* Map an Events-table row (a search hit) to an [EventInstance]. Unlike the
|
||||
@@ -16,17 +15,11 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
|
||||
// long-running birthdays/anniversaries still surface in search.
|
||||
if (isNull(SearchProjection.IDX_DTSTART)) return null
|
||||
val dtStart = getLong(SearchProjection.IDX_DTSTART)
|
||||
val end = when {
|
||||
!isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND)
|
||||
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
|
||||
}.coerceAtLeast(dtStart)
|
||||
val end = endMillis(dtStart, SearchProjection.IDX_DTEND, SearchProjection.IDX_DURATION)
|
||||
.coerceAtLeast(dtStart)
|
||||
|
||||
val title = getString(SearchProjection.IDX_TITLE).orEmpty()
|
||||
val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) {
|
||||
getInt(SearchProjection.IDX_CALENDAR_COLOR)
|
||||
} else {
|
||||
getInt(SearchProjection.IDX_EVENT_COLOR)
|
||||
}
|
||||
val color = getIntOrNull(SearchProjection.IDX_EVENT_COLOR) ?: getInt(SearchProjection.IDX_CALENDAR_COLOR)
|
||||
val eventId = getLong(SearchProjection.IDX_ID)
|
||||
|
||||
return EventInstance(
|
||||
|
||||
+2
-4
@@ -2,16 +2,15 @@ package de.jeanlucmakiola.calendula.data.contacts
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.ContactsContract.CommonDataKinds.Event
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.managedUid
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.parseContactEventDate
|
||||
import de.jeanlucmakiola.floret.android.hasPermission
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -31,8 +30,7 @@ interface ContactSpecialDatesDataSource {
|
||||
|
||||
/** Whether `READ_CONTACTS` is granted — the gate for every contacts read. */
|
||||
fun Context.hasContactsPermission(): Boolean =
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
hasPermission(Manifest.permission.READ_CONTACTS)
|
||||
|
||||
/** Map a `ContactsContract` event `TYPE` to our calendar bucket. */
|
||||
internal fun specialDateTypeForRawEventType(type: Int): SpecialDateType = when (type) {
|
||||
|
||||
+3
-2
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.FormTime
|
||||
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
|
||||
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
||||
@@ -212,8 +213,8 @@ class SpecialDatesSyncEngine @Inject constructor(
|
||||
calendarId = calendarId,
|
||||
title = title,
|
||||
isAllDay = true,
|
||||
start = start,
|
||||
end = start,
|
||||
start = FormTime(start),
|
||||
end = FormTime(start),
|
||||
reminders = reminderCtx.resolveAllDay(calendarId),
|
||||
availability = Availability.Free,
|
||||
rrule = YEARLY_RRULE,
|
||||
|
||||
@@ -21,9 +21,9 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ProviderReminderInstanceSource
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderInstanceSource
|
||||
import de.jeanlucmakiola.calendula.data.time.AndroidTodaySource
|
||||
import de.jeanlucmakiola.calendula.data.time.AndroidZoneSource
|
||||
import de.jeanlucmakiola.calendula.data.time.TodaySource
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import de.jeanlucmakiola.calendula.data.time.ZoneSource
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.calendulaDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
@@ -69,6 +69,12 @@ abstract class DataBindModule {
|
||||
abstract fun bindTodaySource(
|
||||
impl: AndroidTodaySource,
|
||||
): TodaySource
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindZoneSource(
|
||||
impl: AndroidZoneSource,
|
||||
): ZoneSource
|
||||
}
|
||||
|
||||
@Module
|
||||
@@ -79,8 +85,4 @@ object DataProvideModule {
|
||||
@Singleton
|
||||
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||
context.calendulaDataStore
|
||||
|
||||
@Provides
|
||||
@IoDispatcher
|
||||
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.data.di
|
||||
|
||||
import javax.inject.Qualifier
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class IoDispatcher
|
||||
@@ -1,15 +1,13 @@
|
||||
package de.jeanlucmakiola.calendula.data.prefs
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import de.jeanlucmakiola.floret.prefs.Pref
|
||||
import de.jeanlucmakiola.floret.prefs.PrefStore
|
||||
import de.jeanlucmakiola.floret.prefs.nullableBooleanPref
|
||||
import de.jeanlucmakiola.floret.prefs.nullableLongPref
|
||||
import de.jeanlucmakiola.floret.prefs.nullableStringPref
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -26,18 +24,13 @@ import javax.inject.Singleton
|
||||
*/
|
||||
@Singleton
|
||||
class CalendarPrefs @Inject constructor(
|
||||
private val store: DataStore<Preferences>,
|
||||
store: DataStore<Preferences>,
|
||||
) {
|
||||
private val prefs = PrefStore(store)
|
||||
|
||||
// Both id sets are deduped: the store is shared with SettingsPrefs, so any
|
||||
// unrelated write would otherwise re-emit an identical set.
|
||||
val hiddenCalendarIds: Flow<Set<Long>> = store.data
|
||||
.map { prefs -> prefs[HIDDEN_IDS_KEY].parseIds() }
|
||||
.distinctUntilChanged()
|
||||
val hiddenCalendarIds: Flow<Set<Long>> = prefs.flow(HIDDEN_IDS)
|
||||
|
||||
suspend fun setHiddenCalendarIds(ids: Set<Long>) {
|
||||
store.edit { prefs -> prefs.writeIds(HIDDEN_IDS_KEY, ids) }
|
||||
}
|
||||
suspend fun setHiddenCalendarIds(ids: Set<Long>) = prefs.set(HIDDEN_IDS, ids)
|
||||
|
||||
/**
|
||||
* Switch-offs the provider does not know about yet, because writing
|
||||
@@ -48,24 +41,18 @@ class CalendarPrefs @Inject constructor(
|
||||
* second visibility model: `CalendarVisibilityReconciler` drains it entry by
|
||||
* entry as soon as the app may write, and nothing adds to it while it may.
|
||||
*/
|
||||
val pendingDisabledCalendarIds: Flow<Set<Long>> = store.data
|
||||
.map { prefs -> prefs[DISABLED_IDS_KEY].parseIds() }
|
||||
.distinctUntilChanged()
|
||||
val pendingDisabledCalendarIds: Flow<Set<Long>> = prefs.flow(DISABLED_IDS)
|
||||
|
||||
suspend fun addPendingDisabledCalendarIds(ids: Collection<Long>) =
|
||||
editPendingDisabled { it + ids }
|
||||
suspend fun addPendingDisabledCalendarIds(ids: Collection<Long>) {
|
||||
prefs.update(DISABLED_IDS) { it + ids }
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop [ids] from the pending set, one at a time as the reconciler flushes
|
||||
* them, so a run that fails part-way never re-applies what already landed.
|
||||
*/
|
||||
suspend fun removePendingDisabledCalendarIds(ids: Collection<Long>) =
|
||||
editPendingDisabled { it - ids.toSet() }
|
||||
|
||||
private suspend fun editPendingDisabled(transform: (Set<Long>) -> Set<Long>) {
|
||||
store.edit { prefs ->
|
||||
prefs.writeIds(DISABLED_IDS_KEY, transform(prefs[DISABLED_IDS_KEY].parseIds()))
|
||||
}
|
||||
suspend fun removePendingDisabledCalendarIds(ids: Collection<Long>) {
|
||||
prefs.update(DISABLED_IDS) { it - ids.toSet() }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,39 +60,28 @@ class CalendarPrefs @Inject constructor(
|
||||
* owed. Null until the reconciler has evaluated it (which needs the calendar
|
||||
* permission), false once it has been shown or was never needed.
|
||||
*/
|
||||
val visibilityNoticePending: Flow<Boolean?> = store.data.map { prefs ->
|
||||
prefs[VISIBILITY_NOTICE_KEY]
|
||||
}
|
||||
val visibilityNoticePending: Flow<Boolean?> = prefs.flow(VISIBILITY_NOTICE)
|
||||
|
||||
suspend fun setVisibilityNoticePending(pending: Boolean) {
|
||||
store.edit { prefs -> prefs[VISIBILITY_NOTICE_KEY] = pending }
|
||||
}
|
||||
suspend fun setVisibilityNoticePending(pending: Boolean) = prefs.set(VISIBILITY_NOTICE, pending)
|
||||
|
||||
/**
|
||||
* The calendar the user last created an event in; preselected in the
|
||||
* event form. Null until the first event is created.
|
||||
*/
|
||||
val lastUsedCalendarId: Flow<Long?> = store.data.map { prefs ->
|
||||
prefs[LAST_USED_CALENDAR_KEY]
|
||||
}
|
||||
val lastUsedCalendarId: Flow<Long?> = prefs.flow(LAST_USED_CALENDAR)
|
||||
|
||||
suspend fun setLastUsedCalendarId(id: Long) {
|
||||
store.edit { prefs -> prefs[LAST_USED_CALENDAR_KEY] = id }
|
||||
}
|
||||
suspend fun setLastUsedCalendarId(id: Long) = prefs.set(LAST_USED_CALENDAR, id)
|
||||
|
||||
companion object {
|
||||
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
|
||||
internal val DISABLED_IDS_KEY = stringPreferencesKey("disabled_calendar_ids")
|
||||
internal val VISIBILITY_NOTICE_KEY = booleanPreferencesKey("visibility_notice_pending")
|
||||
internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id")
|
||||
private companion object {
|
||||
val HIDDEN_IDS = idSetPref("hidden_calendar_ids")
|
||||
val DISABLED_IDS = idSetPref("disabled_calendar_ids")
|
||||
val VISIBILITY_NOTICE = nullableBooleanPref("visibility_notice_pending")
|
||||
val LAST_USED_CALENDAR = nullableLongPref("last_used_calendar_id")
|
||||
}
|
||||
}
|
||||
|
||||
private fun String?.parseIds(): Set<Long> = orEmpty()
|
||||
.split(',')
|
||||
.mapNotNull { it.trim().toLongOrNull() }
|
||||
.toSet()
|
||||
|
||||
private fun MutablePreferences.writeIds(key: Preferences.Key<String>, ids: Set<Long>) {
|
||||
if (ids.isEmpty()) remove(key) else set(key, ids.sorted().joinToString(","))
|
||||
}
|
||||
/** A comma-joined id set; non-numeric tokens are dropped, and an empty set removes the key. */
|
||||
private fun idSetPref(name: String): Pref<Set<Long>> = nullableStringPref(name).map(
|
||||
{ stored -> stored.orEmpty().split(',').mapNotNull { it.trim().toLongOrNull() }.toSet() },
|
||||
{ ids -> if (ids.isEmpty()) null else ids.sorted().joinToString(",") },
|
||||
)
|
||||
|
||||
@@ -2,10 +2,8 @@ package de.jeanlucmakiola.calendula.data.prefs
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import de.jeanlucmakiola.floret.prefs.PrefStore
|
||||
import de.jeanlucmakiola.floret.prefs.nullableLongPref
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -20,17 +18,16 @@ import javax.inject.Singleton
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderStatePrefs @Inject constructor(
|
||||
private val store: DataStore<Preferences>,
|
||||
store: DataStore<Preferences>,
|
||||
) {
|
||||
private val prefs = PrefStore(store)
|
||||
|
||||
/** The watermark, or `null` before the first scan has ever run. */
|
||||
suspend fun lastScanMillis(): Long? = store.data.map { it[LAST_SCAN_KEY] }.first()
|
||||
suspend fun lastScanMillis(): Long? = prefs.get(LAST_SCAN)
|
||||
|
||||
suspend fun setLastScanMillis(millis: Long) {
|
||||
store.edit { prefs -> prefs[LAST_SCAN_KEY] = millis }
|
||||
}
|
||||
suspend fun setLastScanMillis(millis: Long) = prefs.set(LAST_SCAN, millis)
|
||||
|
||||
private companion object {
|
||||
val LAST_SCAN_KEY = longPreferencesKey("reminder_last_scan_millis")
|
||||
val LAST_SCAN = nullableLongPref("reminder_last_scan_millis")
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+8
-21
@@ -6,11 +6,8 @@ import android.content.Intent
|
||||
import androidx.core.net.toUri
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import de.jeanlucmakiola.floret.android.goAsync
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
@@ -44,27 +41,17 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
// Cancel now so the notification doesn't linger until the alarm;
|
||||
// the snooze delay read is the only async work.
|
||||
notifier.cancel(alert)
|
||||
val pendingResult = goAsync()
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
val minutes = settingsPrefs.snoozeMinutes.first()
|
||||
val triggerAt = System.currentTimeMillis() + minutes * 60_000L
|
||||
scheduler.schedule(alert, triggerAt)
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
goAsync {
|
||||
val minutes = settingsPrefs.snoozeMinutes.first()
|
||||
val triggerAt = System.currentTimeMillis() + minutes * 60_000L
|
||||
scheduler.schedule(alert, triggerAt)
|
||||
}
|
||||
}
|
||||
|
||||
ACTION_SHOW -> {
|
||||
val pendingResult = goAsync()
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) {
|
||||
notifier.post(alert)
|
||||
}
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
goAsync {
|
||||
if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) {
|
||||
notifier.post(alert)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,20 +4,12 @@ import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.content.getSystemService
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.floret.android.setWakeup
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* True on API < 31 (no restriction), and on 31+ when the exact-alarm capability
|
||||
* is held — auto-granted via `USE_EXACT_ALARM` on API 33+ (Calendula is a
|
||||
* calendar app), user-revocable on 31–32.
|
||||
*/
|
||||
internal fun AlarmManager.canScheduleExactCompat(): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || canScheduleExactAlarms()
|
||||
|
||||
/**
|
||||
* Holds the app's own wake-up for the next reminder (#75). Exactly one alarm
|
||||
* exists at a time, for the earliest reminder ahead; every firing re-scans and
|
||||
@@ -31,16 +23,7 @@ class ReminderAlarmScheduler @Inject constructor(
|
||||
|
||||
fun scheduleScan(triggerAtMillis: Long) {
|
||||
val alarmManager = context.getSystemService<AlarmManager>() ?: return
|
||||
val pendingIntent = scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
if (alarmManager.canScheduleExactCompat()) {
|
||||
alarmManager.setExactAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||
)
|
||||
} else {
|
||||
alarmManager.setAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||
)
|
||||
}
|
||||
alarmManager.setWakeup(triggerAtMillis, scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
}
|
||||
|
||||
/** Drop the pending wake-up — reminders are off, or there is nothing to wait for. */
|
||||
|
||||
+2
-5
@@ -1,9 +1,9 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.content.Context
|
||||
import android.content.ContentUris
|
||||
import android.provider.CalendarContract
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.calendar.instancesUri
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -37,10 +37,7 @@ class ProviderReminderInstanceSource @Inject constructor(
|
||||
) : ReminderInstanceSource {
|
||||
|
||||
override fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance> {
|
||||
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
||||
ContentUris.appendId(this, fromMillis)
|
||||
ContentUris.appendId(this, toMillis)
|
||||
}.build()
|
||||
val uri = instancesUri(fromMillis, toMillis)
|
||||
// `visible` is the flag the app's one visibility model writes (#75).
|
||||
// The status clause mirrors CalendarDataSource.instances: NULL means
|
||||
// "normal", so a bare `!= CANCELED` would drop every ordinary event.
|
||||
|
||||
@@ -5,12 +5,10 @@ import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
@@ -19,6 +17,7 @@ import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.floret.android.hasPermission
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.isoDayNumber
|
||||
import java.time.DayOfWeek
|
||||
@@ -48,8 +47,7 @@ class ReminderNotifier @Inject constructor(
|
||||
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
||||
fun canPost(): Boolean {
|
||||
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
context.hasPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||
}
|
||||
|
||||
@@ -63,13 +61,9 @@ class ReminderNotifier @Inject constructor(
|
||||
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
|
||||
calendarDataSource.isCalendarVisible(calendarId) == false
|
||||
|
||||
/**
|
||||
* Post [alert], unless its calendar is switched off. Returns whether the
|
||||
* notification was put up, which the snooze re-show path uses to tell a
|
||||
* silenced reminder from a delivered one.
|
||||
*/
|
||||
suspend fun post(alert: ReminderAlert): Boolean {
|
||||
if (isSilenced(alert.calendarId)) return false
|
||||
/** Post [alert], unless its calendar is switched off. */
|
||||
suspend fun post(alert: ReminderAlert) {
|
||||
if (isSilenced(alert.calendarId)) return
|
||||
ensureChannel()
|
||||
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
val is24Hour = settingsPrefs.timeFormat.first()
|
||||
@@ -122,8 +116,6 @@ class ReminderNotifier @Inject constructor(
|
||||
// POST_NOTIFICATIONS was revoked between canPost() and here.
|
||||
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
|
||||
}
|
||||
// Handled either way — a retry hits the same revoked permission.
|
||||
return true
|
||||
}
|
||||
|
||||
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
|
||||
|
||||
@@ -2,19 +2,18 @@ package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.withoutProviderDefaults
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.planReminders
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.reminderQueryHorizon
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.reminderWatermark
|
||||
import de.jeanlucmakiola.calendula.domain.reminders.scheduleReminders
|
||||
import de.jeanlucmakiola.floret.android.hasPermission
|
||||
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -113,9 +112,7 @@ class ReminderScanner @Inject constructor(
|
||||
alarms.scheduleScan(schedule.nextAlarmMillis)
|
||||
}
|
||||
|
||||
private fun hasReadCalendar(): Boolean = ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.READ_CALENDAR,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
private fun hasReadCalendar(): Boolean = context.hasPermission(Manifest.permission.READ_CALENDAR)
|
||||
|
||||
/**
|
||||
* Re-scan when the provider changes, so a saved or deleted event re-arms the
|
||||
|
||||
+2
-12
@@ -4,10 +4,7 @@ import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import de.jeanlucmakiola.floret.android.goAsync
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
@@ -29,14 +26,7 @@ class ReminderScheduleReceiver : BroadcastReceiver() {
|
||||
// exported and the broadcasts it takes are protected, so any other
|
||||
// action did not come from where it claims to.
|
||||
if (intent.action !in HANDLED_ACTIONS) return
|
||||
val pendingResult = goAsync()
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
scanner.scan()
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
goAsync { scanner.scan() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
+2
-20
@@ -3,9 +3,9 @@ package de.jeanlucmakiola.calendula.data.reminders
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.core.content.getSystemService
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.floret.android.setWakeup
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -31,24 +31,6 @@ class ReminderSnoozeScheduler @Inject constructor(
|
||||
ReminderActionReceiver.intent(context, ReminderActionReceiver.ACTION_SHOW, alert),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
if (canScheduleExact(alarmManager)) {
|
||||
alarmManager.setExactAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||
)
|
||||
} else {
|
||||
// Exact alarms revoked (API 31–32); an inexact wake is the best
|
||||
// available without nagging for SCHEDULE_EXACT_ALARM.
|
||||
alarmManager.setAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
|
||||
)
|
||||
}
|
||||
alarmManager.setWakeup(triggerAtMillis, pendingIntent)
|
||||
}
|
||||
|
||||
/**
|
||||
* True on API < 31 (no restriction), and on 31+ when the exact-alarm
|
||||
* capability is held — auto-granted via `USE_EXACT_ALARM` on API 33+
|
||||
* (Calendula is a calendar app), user-revocable on 31–32.
|
||||
*/
|
||||
private fun canScheduleExact(alarmManager: AlarmManager): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager.canScheduleExactAlarms()
|
||||
}
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
package de.jeanlucmakiola.calendula.data.time
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.floret.android.broadcastTicks
|
||||
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
@@ -34,21 +33,8 @@ class AndroidTodaySource @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : TodaySource {
|
||||
|
||||
override val today: Flow<LocalDate> = callbackFlow {
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
trySend(Unit)
|
||||
}
|
||||
}
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_DATE_CHANGED)
|
||||
addAction(Intent.ACTION_TIME_CHANGED)
|
||||
addAction(Intent.ACTION_TIMEZONE_CHANGED)
|
||||
}
|
||||
ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
awaitClose { context.unregisterReceiver(receiver) }
|
||||
}
|
||||
.onStart { emit(Unit) }
|
||||
override val today: Flow<LocalDate> = context
|
||||
.broadcastTicks(Intent.ACTION_DATE_CHANGED, Intent.ACTION_TIME_CHANGED, Intent.ACTION_TIMEZONE_CHANGED)
|
||||
.transformLatest {
|
||||
while (true) {
|
||||
emit(currentDate())
|
||||
@@ -61,3 +47,21 @@ class AndroidTodaySource @Inject constructor(
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package de.jeanlucmakiola.calendula.data.time
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.floret.android.broadcastTicks
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.datetime.TimeZone
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The zone the device is in. Every screen that turns wall clock into instants
|
||||
* reads it from here, so the timeline, the event form and a saved move use the
|
||||
* same zone (#387).
|
||||
*/
|
||||
interface ZoneSource {
|
||||
/** The device's zone, re-emitted whenever it changes. */
|
||||
val zone: Flow<TimeZone>
|
||||
|
||||
/** The device's zone right now. */
|
||||
fun current(): TimeZone
|
||||
}
|
||||
|
||||
/** Follows the system's zone broadcast. */
|
||||
class AndroidZoneSource @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ZoneSource {
|
||||
|
||||
override val zone: Flow<TimeZone> = context.broadcastTicks(Intent.ACTION_TIMEZONE_CHANGED)
|
||||
.map { current() }
|
||||
.distinctUntilChanged()
|
||||
|
||||
override fun current(): TimeZone = TimeZone.currentSystemDefault()
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.UtcOffset
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
@@ -17,8 +17,8 @@ data class EventForm(
|
||||
val calendarId: Long?,
|
||||
val title: String = "",
|
||||
val isAllDay: Boolean = false,
|
||||
val start: LocalDateTime,
|
||||
val end: LocalDateTime,
|
||||
val start: FormTime,
|
||||
val end: FormTime,
|
||||
/**
|
||||
* The zone [start]/[end] are wall-clock values in, or null to follow the
|
||||
* device — null is not "no zone", it is "whichever zone the device is in
|
||||
@@ -70,6 +70,19 @@ data class EventForm(
|
||||
val attendees: List<EventAttendee> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One end of a form's span: a wall-clock [dateTime] in the form's zone, and the
|
||||
* [offset] it was read at when the fall-back hour repeats that wall clock — both
|
||||
* of the hour's instants share one [LocalDateTime] (#387). Null everywhere else,
|
||||
* so a time changed and changed back compares equal again. A time picked by hand
|
||||
* gets its pass from [repicked]; one without an offset reads as the earlier
|
||||
* pass. One value, so a time never changes without its pass. See [toFormTime].
|
||||
*/
|
||||
data class FormTime(val dateTime: LocalDateTime, val offset: UtcOffset? = null) {
|
||||
val date: LocalDate get() = dateTime.date
|
||||
val time: LocalTime get() = dateTime.time
|
||||
}
|
||||
|
||||
/**
|
||||
* One editable guest: the user controls the [email] (the identity we dedup and
|
||||
* write on), an optional display [name], and whether they're [optional] rather
|
||||
@@ -135,26 +148,23 @@ enum class EventFormProblem {
|
||||
fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm {
|
||||
// All-day events are date-anchored and carry a nominal "UTC" that is an
|
||||
// anchor, not a location, so they never pin a zone.
|
||||
val pinnedZone = if (instance.isAllDay) {
|
||||
val pinned = if (instance.isAllDay) {
|
||||
null
|
||||
} else {
|
||||
eventTimezone
|
||||
?.takeIf { it != zone.id }
|
||||
// An unparseable id (a malformed sync row) can't be honoured or
|
||||
// shown; fall back to the device zone rather than failing the open.
|
||||
?.takeIf { runCatching { TimeZone.of(it) }.isSuccess }
|
||||
?.let { id -> zoneOrNull(id)?.let { id to it } }
|
||||
}
|
||||
val formZone = pinnedZone?.let { TimeZone.of(it) } ?: zone
|
||||
val pinnedZone = pinned?.first
|
||||
val formZone = pinned?.second ?: zone
|
||||
val begin = Instant.fromEpochMilliseconds(beginMillis)
|
||||
val finish = Instant.fromEpochMilliseconds(endMillis)
|
||||
val (start, end) = if (instance.isAllDay) {
|
||||
val startDate = Instant.fromEpochMilliseconds(beginMillis)
|
||||
.toLocalDateTime(TimeZone.UTC).date
|
||||
val endExclusive = Instant.fromEpochMilliseconds(endMillis)
|
||||
.toLocalDateTime(TimeZone.UTC).date
|
||||
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
|
||||
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
||||
allDayFormTimes(begin.toLocalDateTime(TimeZone.UTC).date, finish.toLocalDateTime(TimeZone.UTC).date)
|
||||
} else {
|
||||
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(formZone) to
|
||||
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(formZone)
|
||||
begin.toFormTime(formZone) to finish.toFormTime(formZone)
|
||||
}
|
||||
return EventForm(
|
||||
calendarId = instance.calendarId,
|
||||
@@ -168,7 +178,7 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
||||
reminders = reminders.map { it.minutes }.distinct().sorted(),
|
||||
availability = availability,
|
||||
accessLevel = accessLevel,
|
||||
rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
||||
rrule = rrule.normalizedRrule(),
|
||||
// The provider fills EVENT_COLOR from the key, so [color] is the
|
||||
// swatch either way; a null colour means the event inherits its
|
||||
// calendar's colour.
|
||||
@@ -221,20 +231,30 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
|
||||
)
|
||||
|
||||
/**
|
||||
* The form's times as they land in [target] — what a pinned event's wall-clock
|
||||
* actually means where the user is standing. Null when there's nothing to
|
||||
* disambiguate: an unpinned event (already in [target]), one pinned to [target]
|
||||
* itself, an all-day event (no zone), or an unparseable pinned zone.
|
||||
*
|
||||
* The form edits a pinned event in its own zone, so this is what lets the UI
|
||||
* show the other side of the pair rather than making the user do the arithmetic.
|
||||
* Form times for an all-day span stored as UTC midnights with an exclusive
|
||||
* end: the last covered day (never before [start]), with placeholder
|
||||
* wall-clock times kept in case the user switches the event to timed.
|
||||
*/
|
||||
fun EventForm.timesIn(target: TimeZone): Pair<LocalDateTime, LocalDateTime>? {
|
||||
if (isAllDay) return null
|
||||
val pinned = timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: return null
|
||||
if (pinned.id == target.id) return null
|
||||
return start.toInstant(pinned).toLocalDateTime(target) to
|
||||
end.toInstant(pinned).toLocalDateTime(target)
|
||||
internal fun allDayFormTimes(start: LocalDate, endExclusive: LocalDate?): Pair<FormTime, FormTime> {
|
||||
val end = endExclusive?.let { maxOf(start, LocalDate.fromEpochDays(it.toEpochDays() - 1)) } ?: start
|
||||
return FormTime(LocalDateTime(start, LocalTime(9, 0))) to FormTime(LocalDateTime(end, LocalTime(10, 0)))
|
||||
}
|
||||
|
||||
/** The bare RRULE value the form edits; blank means none. */
|
||||
internal fun String?.normalizedRrule(): String? = this?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() }
|
||||
|
||||
/**
|
||||
* Whether this form's times mean something else in [target] — a timed event
|
||||
* pinned to another zone. False when there's nothing to disambiguate: an
|
||||
* unpinned event (already in [target]), one pinned to [target] itself, an
|
||||
* all-day event (no zone), or an unparseable pinned zone.
|
||||
*
|
||||
* The form edits a pinned event in its own zone, so this is what tells the UI
|
||||
* to show the other side of the pair rather than making the user do the arithmetic.
|
||||
*/
|
||||
fun EventForm.isPinnedAwayFrom(target: TimeZone): Boolean {
|
||||
if (isAllDay) return false
|
||||
return resolvedZone(target).id != target.id
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -254,9 +274,19 @@ fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
|
||||
if (attendees.isNotEmpty()) add(EventFormField.Attendees)
|
||||
}
|
||||
|
||||
fun EventForm.problems(): Set<EventFormProblem> = buildSet {
|
||||
/**
|
||||
* What stops this form from saving. A timed span is checked as instants in the
|
||||
* form's zone (else [deviceZone]): one ending in the fall-back hour's repeat can
|
||||
* end on an earlier wall clock than it starts (#387).
|
||||
*/
|
||||
fun EventForm.problems(deviceZone: TimeZone): Set<EventFormProblem> = buildSet {
|
||||
if (calendarId == null) add(EventFormProblem.NoCalendar)
|
||||
val endsTooEarly = if (isAllDay) end.date < start.date else end < start
|
||||
val endsTooEarly = if (isAllDay) {
|
||||
end.date < start.date
|
||||
} else {
|
||||
val zone = resolvedZone(deviceZone)
|
||||
endInstant(zone) < startInstant(zone)
|
||||
}
|
||||
if (endsTooEarly) add(EventFormProblem.EndBeforeStart)
|
||||
// An UNTIL before the first day would make the provider generate zero
|
||||
// occurrences — the event would silently vanish from every view.
|
||||
@@ -265,3 +295,20 @@ fun EventForm.problems(): Set<EventFormProblem> = buildSet {
|
||||
add(EventFormProblem.RecurrenceEndsBeforeStart)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A create form seeded from this occurrence, to save a copy as a new,
|
||||
* independent event (#52). The recurrence is dropped — a duplicate is a single
|
||||
* event. The source calendar is kept only when [canModify], otherwise the id is
|
||||
* cleared so the create resolves to a writable calendar, which lets a read-only
|
||||
* event be copied into an editable one. [instance]'s own times carry over.
|
||||
*/
|
||||
fun EventDetail.toDuplicateForm(canModify: Boolean, zone: TimeZone): EventForm =
|
||||
toEditForm(
|
||||
beginMillis = instance.start.toEpochMilliseconds(),
|
||||
endMillis = instance.end.toEpochMilliseconds(),
|
||||
zone = zone,
|
||||
).copy(
|
||||
rrule = null,
|
||||
calendarId = instance.calendarId.takeIf { canModify },
|
||||
)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.number
|
||||
import kotlinx.datetime.plus
|
||||
@@ -101,19 +101,13 @@ object EventSearch {
|
||||
|
||||
val today = todayStart.toLocalDateTime(zone).date
|
||||
val matched = candidates.mapNotNull { candidate -> match(candidate, tokens, now, zone) }
|
||||
val upcoming = { m: Matched -> m.event.spanLastDay(zone) >= today }
|
||||
val order = compareBy<Matched> { if (upcoming(it)) 0 else 1 }
|
||||
.thenComparator { a, b ->
|
||||
// What is left reads forwards; what is behind us reads backwards
|
||||
// from today, nearest first.
|
||||
if (upcoming(a)) {
|
||||
a.event.dayStart(zone).compareTo(b.event.dayStart(zone))
|
||||
} else {
|
||||
b.event.dayStart(zone).compareTo(a.event.dayStart(zone))
|
||||
}
|
||||
}
|
||||
val (upcoming, past) = matched.partition { it.event.spanLastDay(zone) >= today }
|
||||
// What is left reads forwards; what is behind us reads backwards from
|
||||
// today, nearest first.
|
||||
val ordered = upcoming.sortedBy { it.event.dayStart(zone) } +
|
||||
past.sortedByDescending { it.event.dayStart(zone) }
|
||||
|
||||
val (inDescriptions, dated) = matched.sortedWith(order).partition { it.descriptionOnly }
|
||||
val (inDescriptions, dated) = ordered.partition { it.descriptionOnly }
|
||||
return SearchResults(
|
||||
months = groupByMonth(dated, zone, todayStart),
|
||||
inDescriptions = inDescriptions.map { it.hit },
|
||||
@@ -126,33 +120,18 @@ object EventSearch {
|
||||
zone: TimeZone,
|
||||
todayStart: Instant,
|
||||
): List<SearchMonth> {
|
||||
val currentKey = monthKey(todayStart.toLocalDateTime(zone).date)
|
||||
return dated
|
||||
.groupBy { monthKey(it.event.spanFirstDay(zone)) }
|
||||
.entries
|
||||
.sortedWith(
|
||||
compareBy<Map.Entry<Int, List<Matched>>> {
|
||||
when {
|
||||
it.key == currentKey -> 0
|
||||
it.key > currentKey -> 1
|
||||
else -> 2
|
||||
}
|
||||
}.thenComparator { a, b ->
|
||||
if (a.key > currentKey) a.key.compareTo(b.key) else b.key.compareTo(a.key)
|
||||
},
|
||||
val current = todayStart.toLocalDateTime(zone).date.let { YearMonth(it.year, it.month) }
|
||||
val groups = dated.groupBy { m -> m.event.spanFirstDay(zone).let { YearMonth(it.year, it.month) } }
|
||||
val (behind, ahead) = groups.keys.partition { it < current }
|
||||
return (ahead.sorted() + behind.sortedDescending()).map { month ->
|
||||
SearchMonth(
|
||||
year = month.year,
|
||||
monthNumber = month.month.number,
|
||||
hits = groups.getValue(month).map { it.hit },
|
||||
)
|
||||
.map { (key, entries) ->
|
||||
SearchMonth(
|
||||
year = key / 12,
|
||||
monthNumber = key % 12 + 1,
|
||||
hits = entries.map { it.hit },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Year and month as one comparable number. */
|
||||
private fun monthKey(date: LocalDate): Int = date.year * 12 + (date.month.number - 1)
|
||||
|
||||
private fun match(
|
||||
candidate: SearchCandidate,
|
||||
tokens: List<String>,
|
||||
@@ -162,16 +141,20 @@ object EventSearch {
|
||||
val title = candidate.title?.takeIf { it.isNotBlank() }
|
||||
val location = candidate.event.location?.takeIf { it.isNotBlank() }
|
||||
// Collapsed so a multi-line description matches the way it is drawn.
|
||||
val description = candidate.description
|
||||
val rich = candidate.description?.let(::descriptionRichText)
|
||||
val description = rich?.text
|
||||
?.replace(WHITESPACE, " ")
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
// A link's address isn't in the drawn text, but "zoom.us" should still find the invite.
|
||||
val linkUrls = rich?.links.orEmpty().map { it.url }
|
||||
|
||||
// Mid-word counts: "termin" has to keep finding "Zahnarzttermin".
|
||||
val present = { token: String ->
|
||||
title?.contains(token, ignoreCase = true) == true ||
|
||||
location?.contains(token, ignoreCase = true) == true ||
|
||||
description?.contains(token, ignoreCase = true) == true
|
||||
description?.contains(token, ignoreCase = true) == true ||
|
||||
linkUrls.any { it.contains(token, ignoreCase = true) }
|
||||
}
|
||||
if (!tokens.all(present)) return null
|
||||
|
||||
|
||||
@@ -3,17 +3,137 @@ package de.jeanlucmakiola.calendula.domain
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.UtcOffset
|
||||
import kotlinx.datetime.offsetAt
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toJavaLocalDateTime
|
||||
import kotlinx.datetime.toJavaZoneId
|
||||
import kotlinx.datetime.toJavaZoneOffset
|
||||
import kotlinx.datetime.toKotlinUtcOffset
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import java.time.ZonedDateTime
|
||||
import kotlin.time.Instant
|
||||
import kotlin.time.toJavaInstant
|
||||
import kotlin.time.toKotlinInstant
|
||||
|
||||
/** [id] as a zone, or null when the tz database doesn't know it. */
|
||||
internal fun zoneOrNull(id: String): TimeZone? = runCatching { TimeZone.of(id) }.getOrNull()
|
||||
|
||||
/**
|
||||
* The zone this form's wall-clock times mean, matching what the data layer
|
||||
* resolves them in at write time: the form's own pinned zone, else [deviceZone].
|
||||
*/
|
||||
fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone =
|
||||
timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: deviceZone
|
||||
timezone?.let(::zoneOrNull) ?: deviceZone
|
||||
|
||||
/**
|
||||
* This wall-clock time in [zone], at [preferred] when the fall-back hour repeats
|
||||
* it and that is one of its two offsets — otherwise the earlier one (#387).
|
||||
*/
|
||||
fun LocalDateTime.resolveIn(zone: TimeZone, preferred: UtcOffset?): Instant =
|
||||
ZonedDateTime.ofLocal(toJavaLocalDateTime(), zone.toJavaZoneId(), preferred?.toJavaZoneOffset())
|
||||
.toInstant().toKotlinInstant()
|
||||
|
||||
/** Whether this instant's wall clock in [zone] is one the fall-back hour repeats. */
|
||||
fun Instant.inRepeatedHour(zone: TimeZone): Boolean {
|
||||
val zid = zone.toJavaZoneId()
|
||||
return zid.rules.getTransition(java.time.LocalDateTime.ofInstant(toJavaInstant(), zid))?.isOverlap == true
|
||||
}
|
||||
|
||||
/**
|
||||
* [at]'s offset in this zone when its wall clock is one the fall-back hour
|
||||
* repeats, else null: elsewhere the wall clock alone already names the instant.
|
||||
* What a [FormTime.offset] holds (#387).
|
||||
*/
|
||||
fun TimeZone.repeatedHourOffsetAt(at: Instant): UtcOffset? = if (at.inRepeatedHour(this)) offsetAt(at) else null
|
||||
|
||||
/** This instant as a [FormTime] in [zone], keeping its pass of a repeated hour. */
|
||||
fun Instant.toFormTime(zone: TimeZone): FormTime = FormTime(toLocalDateTime(zone), zone.repeatedHourOffsetAt(this))
|
||||
|
||||
/** This time as an instant in [zone], on its kept pass of a repeated hour. */
|
||||
fun FormTime.instantIn(zone: TimeZone): Instant = dateTime.resolveIn(zone, offset)
|
||||
|
||||
/**
|
||||
* This edge of an event moved to [picked] by hand — a picker or a resize handle —
|
||||
* as a [FormTime] in [zone]. A time in the spring-forward gap moves on to the
|
||||
* real one, as the provider would read it. A wall clock the fall-back hour
|
||||
* repeats has two instants; which one it means (#387):
|
||||
* - [original]'s, when [picked] is the wall clock the event was opened with, so
|
||||
* a time changed and changed back is the time it was;
|
||||
* - else the one that moves the edge in the direction the user moved it:
|
||||
* earlier than this edge's instant when [picked] is before [shownAt] (where
|
||||
* the edge was on screen), later when after;
|
||||
* - kept within [notBefore]..[notAfter] (the other edge), and to the ones
|
||||
* [drawable] accepts — where the screen can show it — when any fit.
|
||||
*/
|
||||
fun FormTime.repicked(
|
||||
picked: LocalDateTime,
|
||||
zone: TimeZone,
|
||||
shownAt: LocalDateTime = dateTime,
|
||||
original: FormTime? = null,
|
||||
notBefore: Instant? = null,
|
||||
notAfter: Instant? = null,
|
||||
drawable: (Instant) -> Boolean = { true },
|
||||
): FormTime {
|
||||
if (picked == dateTime && picked == shownAt) return this
|
||||
val offsets = zone.toJavaZoneId().rules.getValidOffsets(picked.toJavaLocalDateTime())
|
||||
if (offsets.isEmpty()) return FormTime(picked).instantIn(zone).toFormTime(zone)
|
||||
if (offsets.size == 1) return FormTime(picked)
|
||||
val passes = offsets.map { picked.toInstant(it.toKotlinUtcOffset()) }.sorted()
|
||||
val bounded = passes.filter { (notBefore == null || it >= notBefore) && (notAfter == null || it <= notAfter) }
|
||||
.ifEmpty { passes }
|
||||
val fitting = bounded.filter(drawable).ifEmpty { bounded }
|
||||
if (original?.dateTime == picked && original.offset != null && original.instantIn(zone) in fitting) return original
|
||||
val from = instantIn(zone)
|
||||
val chosen = when {
|
||||
picked < shownAt -> fitting.lastOrNull { it < from }
|
||||
picked > shownAt -> fitting.firstOrNull { it > from }
|
||||
else -> null
|
||||
} ?: fitting.minBy { (it - from).absoluteValue }
|
||||
return chosen.toFormTime(zone)
|
||||
}
|
||||
|
||||
/** The form's start as an instant in [zone]. */
|
||||
fun EventForm.startInstant(zone: TimeZone): Instant = start.instantIn(zone)
|
||||
|
||||
/** The form's end as an instant in [zone]. */
|
||||
fun EventForm.endInstant(zone: TimeZone): Instant = end.instantIn(zone)
|
||||
|
||||
/** The form's timed span set to these instants, read in [zone]. */
|
||||
fun EventForm.withTimes(start: Instant, end: Instant, zone: TimeZone): EventForm =
|
||||
copy(start = start.toFormTime(zone), end = end.toFormTime(zone))
|
||||
|
||||
/**
|
||||
* The form pinned to [zoneId] (null: the device). A kept pass of a repeated hour
|
||||
* was one of the old zone's, so it goes when the zone the times mean changes.
|
||||
*/
|
||||
fun EventForm.withTimezone(zoneId: String?, deviceZone: TimeZone): EventForm {
|
||||
val pinned = copy(timezone = zoneId)
|
||||
if (pinned.resolvedZone(deviceZone) == resolvedZone(deviceZone)) return pinned
|
||||
return pinned.withoutKeptPasses()
|
||||
}
|
||||
|
||||
/**
|
||||
* The form with its start picked by hand, keeping its length. A repeated hour's
|
||||
* pass is [repicked]: [original] is the start the event was opened with.
|
||||
*/
|
||||
fun EventForm.withStart(start: LocalDateTime, zone: TimeZone, original: FormTime? = null): EventForm {
|
||||
val span = endInstant(zone) - startInstant(zone)
|
||||
val startAt = this.start.repicked(start, zone, original = original).instantIn(zone)
|
||||
return withTimes(startAt, startAt + span, zone)
|
||||
}
|
||||
|
||||
/**
|
||||
* The form with its end picked by hand. A repeated hour's pass is [repicked],
|
||||
* kept after the start where it can be: [original] is the end the event was
|
||||
* opened with.
|
||||
*/
|
||||
fun EventForm.withEnd(end: LocalDateTime, zone: TimeZone, original: FormTime? = null): EventForm =
|
||||
copy(end = this.end.repicked(end, zone, original = original, notBefore = startInstant(zone)))
|
||||
|
||||
/** The form with no kept passes: its times as the wall clock alone says them. */
|
||||
fun EventForm.withoutKeptPasses(): EventForm = copy(start = FormTime(start.dateTime), end = FormTime(end.dateTime))
|
||||
|
||||
/**
|
||||
* The form moved so it starts at [newStart], keeping its **instant** duration —
|
||||
@@ -26,11 +146,8 @@ fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone =
|
||||
fun EventForm.shiftedTo(newStart: Instant, deviceZone: TimeZone): EventForm {
|
||||
if (isAllDay) return this
|
||||
val zone = resolvedZone(deviceZone)
|
||||
val span = end.toInstant(zone) - start.toInstant(zone)
|
||||
return copy(
|
||||
start = newStart.toLocalDateTime(zone),
|
||||
end = (newStart + span).toLocalDateTime(zone),
|
||||
)
|
||||
val span = endInstant(zone) - startInstant(zone)
|
||||
return withTimes(newStart, newStart + span, zone)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,11 +160,21 @@ fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm {
|
||||
val newStart = LocalDateTime(start.date.plus(days, DateTimeUnit.DAY), start.time)
|
||||
if (isAllDay) {
|
||||
return copy(
|
||||
start = newStart,
|
||||
end = LocalDateTime(end.date.plus(days, DateTimeUnit.DAY), end.time),
|
||||
start = FormTime(newStart),
|
||||
end = FormTime(LocalDateTime(end.date.plus(days, DateTimeUnit.DAY), end.time)),
|
||||
)
|
||||
}
|
||||
val zone = resolvedZone(deviceZone)
|
||||
val span = end.toInstant(zone) - start.toInstant(zone)
|
||||
return copy(start = newStart, end = (newStart.toInstant(zone) + span).toLocalDateTime(zone))
|
||||
val span = endInstant(zone) - startInstant(zone)
|
||||
val startAt = FormTime(newStart, start.offset).instantIn(zone)
|
||||
// The start keeps the wall clock it was moved to, even one the spring-forward
|
||||
// gap skips, so a recurring series still sees a whole-day move.
|
||||
val movedStart = startAt.toFormTime(zone).takeIf { it.dateTime == newStart } ?: FormTime(newStart)
|
||||
return copy(start = movedStart, end = (startAt + span).toFormTime(zone))
|
||||
}
|
||||
|
||||
/** The form with new start and end, each in the form's own zone — a resize. All-day is untouched. */
|
||||
fun EventForm.resizedTo(newStart: Instant, newEnd: Instant, deviceZone: TimeZone): EventForm {
|
||||
if (isAllDay) return this
|
||||
return withTimes(newStart, newEnd, resolvedZone(deviceZone))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
/** Inline formatting an HTML description keeps; fonts, sizes and colours are dropped. */
|
||||
enum class InlineStyle { Bold, Italic, Underline, Strikethrough, Superscript, Subscript, Monospace }
|
||||
|
||||
data class StyleRange(val start: Int, val end: Int, val style: InlineStyle)
|
||||
|
||||
/** A link from an `<a href>`, spanning [start] until [end] (exclusive). */
|
||||
data class LinkRange(val start: Int, val end: Int, val url: String)
|
||||
|
||||
/** Text as drawn, with the formatting and links that sit on it. Plain text has neither. */
|
||||
data class RichText(
|
||||
val text: String,
|
||||
val styles: List<StyleRange> = emptyList(),
|
||||
val links: List<LinkRange> = emptyList(),
|
||||
) {
|
||||
/**
|
||||
* [text] with each link's address after it, where the text doesn't already
|
||||
* show it, so a copied "Join the meeting" still carries its URL.
|
||||
*/
|
||||
fun copyText(): String {
|
||||
if (links.isEmpty()) return text
|
||||
val out = StringBuilder(text)
|
||||
for (link in links.sortedByDescending { it.end }) {
|
||||
val address = link.url.removePrefix("mailto:").removePrefix("tel:")
|
||||
if (address !in text.substring(link.start, link.end)) out.insert(link.end, " ($address)")
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
}
|
||||
|
||||
fun looksLikeHtml(text: String): Boolean = htmlTagRegex.containsMatchIn(text)
|
||||
|
||||
/** [description] as it reads: HTML rendered down to [RichText], plain text as it is. */
|
||||
fun descriptionRichText(description: String): RichText =
|
||||
if (looksLikeHtml(description)) parseHtml(description) else RichText(description)
|
||||
|
||||
private val BLOCK_TAGS = setOf(
|
||||
"p", "div", "ul", "ol", "li", "table", "tr", "blockquote", "pre", "hr", "section", "article",
|
||||
"header", "footer", "dl", "dt", "dd", "address", "center", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||
)
|
||||
|
||||
// Their content is never text to show.
|
||||
private val SKIPPED_TAGS = setOf("head", "style", "script", "title", "xml", "template")
|
||||
|
||||
private val STYLE_TAGS = mapOf(
|
||||
"b" to InlineStyle.Bold, "strong" to InlineStyle.Bold, "th" to InlineStyle.Bold,
|
||||
"h1" to InlineStyle.Bold, "h2" to InlineStyle.Bold, "h3" to InlineStyle.Bold,
|
||||
"h4" to InlineStyle.Bold, "h5" to InlineStyle.Bold, "h6" to InlineStyle.Bold,
|
||||
"i" to InlineStyle.Italic, "em" to InlineStyle.Italic, "cite" to InlineStyle.Italic,
|
||||
"u" to InlineStyle.Underline, "ins" to InlineStyle.Underline,
|
||||
"s" to InlineStyle.Strikethrough, "strike" to InlineStyle.Strikethrough, "del" to InlineStyle.Strikethrough,
|
||||
"sup" to InlineStyle.Superscript, "sub" to InlineStyle.Subscript,
|
||||
"code" to InlineStyle.Monospace, "tt" to InlineStyle.Monospace, "pre" to InlineStyle.Monospace,
|
||||
)
|
||||
|
||||
// A real tag the renderer knows, not prose like "<3" or "a < b". Google
|
||||
// Calendar and Exchange both store their descriptions as HTML.
|
||||
private val htmlTagRegex = Regex(
|
||||
(BLOCK_TAGS + SKIPPED_TAGS + STYLE_TAGS.keys + setOf("html", "body", "br", "span", "a", "font", "img", "meta", "td"))
|
||||
.joinToString("|", prefix = """</?(?:""", postfix = """)(?:\s[^<>]*)?/?>"""),
|
||||
RegexOption.IGNORE_CASE,
|
||||
)
|
||||
|
||||
private val LINK_SCHEMES = listOf("http://", "https://", "mailto:", "tel:")
|
||||
|
||||
private const val NBSP = '\u00A0'
|
||||
private const val CELL_SEPARATOR = " · "
|
||||
|
||||
/**
|
||||
* Render [html] down to text with its inline formatting and links. Blocks and
|
||||
* `<br>` become line breaks, list items get a bullet or number, table rows a
|
||||
* line each with their cells side by side. Lenient about broken markup:
|
||||
* whatever doesn't parse as a tag stays text.
|
||||
*/
|
||||
fun parseHtml(html: String): RichText = HtmlRenderer(html).render()
|
||||
|
||||
// Markup that sets its own lines. Without any, the text's newlines are the
|
||||
// lines: "Agenda:\n1. Intro" with one <a href> in it stays on two lines.
|
||||
private val lineTagRegex = Regex("""<(?:br|p|div|li|tr|h[1-6]|pre|blockquote|table)\b""", RegexOption.IGNORE_CASE)
|
||||
|
||||
private class HtmlRenderer(private val html: String) {
|
||||
private val out = StringBuilder()
|
||||
private val styles = mutableListOf<StyleRange>()
|
||||
private val links = mutableListOf<LinkRange>()
|
||||
private val openStyles = mutableListOf<Pair<String, Int>>()
|
||||
private var openLink: Pair<String, Int>? = null
|
||||
private val lists = mutableListOf<ListState>()
|
||||
private val keepNewlines = !lineTagRegex.containsMatchIn(html)
|
||||
private var preDepth = 0
|
||||
private var rowStart = 0
|
||||
private var cellNeedsSeparator = false
|
||||
// Where the current list item's bullet ends, so a <p> inside the item
|
||||
// doesn't push its text off the bullet's line.
|
||||
private var markerEnd = -1
|
||||
|
||||
private class ListState(val ordered: Boolean, var count: Int = 0)
|
||||
|
||||
fun render(): RichText {
|
||||
var i = 0
|
||||
while (i < html.length) {
|
||||
val lt = html.indexOf('<', i)
|
||||
if (lt < 0) {
|
||||
text(html.substring(i))
|
||||
break
|
||||
}
|
||||
if (lt > i) text(html.substring(i, lt))
|
||||
i = tag(lt)
|
||||
}
|
||||
return normalize()
|
||||
}
|
||||
|
||||
/** Handle the markup at [lt]; returns where reading continues. */
|
||||
private fun tag(lt: Int): Int {
|
||||
if (html.startsWith("<!--", lt)) {
|
||||
val end = html.indexOf("-->", lt + 4)
|
||||
return if (end < 0) html.length else end + 3
|
||||
}
|
||||
if (html.startsWith("<!", lt) || html.startsWith("<?", lt)) {
|
||||
val end = html.indexOf('>', lt)
|
||||
return if (end < 0) html.length else end + 1
|
||||
}
|
||||
val closing = html.getOrNull(lt + 1) == '/'
|
||||
val nameStart = if (closing) lt + 2 else lt + 1
|
||||
if (html.getOrNull(nameStart)?.isLetter() != true) {
|
||||
text("<")
|
||||
return lt + 1
|
||||
}
|
||||
val end = tagEnd(lt)
|
||||
if (end < 0) {
|
||||
text("<")
|
||||
return lt + 1
|
||||
}
|
||||
var nameEnd = nameStart
|
||||
while (nameEnd < end && (html[nameEnd].isLetterOrDigit() || html[nameEnd] == ':')) nameEnd++
|
||||
val name = html.substring(nameStart, nameEnd).lowercase()
|
||||
val attrs = html.substring(nameEnd, end)
|
||||
if (closing) {
|
||||
close(name)
|
||||
} else if (name in SKIPPED_TAGS && !attrs.trimEnd().endsWith("/")) {
|
||||
// Never closed (an omitted </head> is valid): just ignore the tag.
|
||||
val closeAt = html.indexOf("</$name", end, ignoreCase = true)
|
||||
if (closeAt < 0) return end + 1
|
||||
val closeEnd = html.indexOf('>', closeAt)
|
||||
return if (closeEnd < 0) html.length else closeEnd + 1
|
||||
} else {
|
||||
open(name, attrs)
|
||||
}
|
||||
return end + 1
|
||||
}
|
||||
|
||||
/** The `>` closing the tag at [lt], skipping any inside quoted attributes. */
|
||||
private fun tagEnd(lt: Int): Int {
|
||||
var quote: Char? = null
|
||||
for (j in lt + 1 until html.length) {
|
||||
val c = html[j]
|
||||
when {
|
||||
quote != null -> if (c == quote) quote = null
|
||||
c == '"' || c == '\'' -> quote = c
|
||||
c == '>' -> return j
|
||||
c == '<' -> return -1
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
private fun open(name: String, attrs: String) {
|
||||
when (name) {
|
||||
"br" -> newline()
|
||||
"li" -> {
|
||||
markerEnd = -1
|
||||
cellNeedsSeparator = false
|
||||
lineBreak()
|
||||
val list = lists.lastOrNull()
|
||||
out.append("$NBSP$NBSP".repeat((lists.size - 1).coerceAtLeast(0)))
|
||||
if (list?.ordered == true) {
|
||||
list.count++
|
||||
out.append("${list.count}. ")
|
||||
} else {
|
||||
out.append("• ")
|
||||
}
|
||||
markerEnd = out.length
|
||||
}
|
||||
"ul", "ol" -> {
|
||||
cellNeedsSeparator = false
|
||||
lineBreak()
|
||||
val start = startRegex.find(attrs)?.groupValues?.get(1)?.toIntOrNull() ?: 1
|
||||
lists += ListState(ordered = name == "ol", count = start - 1)
|
||||
}
|
||||
"tr" -> {
|
||||
lineBreak()
|
||||
rowStart = out.length
|
||||
cellNeedsSeparator = false
|
||||
}
|
||||
"td", "th" -> {
|
||||
// An empty spacer cell leaves nothing behind, not even its .
|
||||
if (rowStart <= out.length && (rowStart until out.length).none { out[it].isContent() }) {
|
||||
cut(rowStart)
|
||||
} else {
|
||||
cellNeedsSeparator = true
|
||||
}
|
||||
}
|
||||
"a" -> {
|
||||
closeLink()
|
||||
hrefRegex.find(attrs)
|
||||
?.groupValues?.drop(1)?.firstOrNull { it.isNotEmpty() }
|
||||
?.let(::decodeEntities)
|
||||
?.trim()
|
||||
?.let { href ->
|
||||
val scheme = LINK_SCHEMES.firstOrNull { href.startsWith(it, ignoreCase = true) }
|
||||
?: return@let
|
||||
// Intent filters match the scheme case-sensitively.
|
||||
openLink = scheme + href.substring(scheme.length) to out.length
|
||||
}
|
||||
}
|
||||
else -> if (name in BLOCK_TAGS) lineBreak()
|
||||
}
|
||||
if (name == "pre") preDepth++
|
||||
STYLE_TAGS[name]?.let { openStyles += name to out.length }
|
||||
}
|
||||
|
||||
private fun close(name: String) {
|
||||
// A row's last cell, if empty, leaves a separator pending; it ends with the row.
|
||||
if (name == "tr" || name == "table") cellNeedsSeparator = false
|
||||
val index = openStyles.indexOfLast { it.first == name }
|
||||
if (index >= 0) {
|
||||
val (_, start) = openStyles.removeAt(index)
|
||||
if (out.length > start) styles += StyleRange(start, out.length, STYLE_TAGS.getValue(name))
|
||||
}
|
||||
if (name == "pre") preDepth = (preDepth - 1).coerceAtLeast(0)
|
||||
if (name == "li" || name == "ul" || name == "ol") {
|
||||
if (out.length == markerEnd) cut(out.lastIndexOf("\n") + 1)
|
||||
markerEnd = -1
|
||||
}
|
||||
when (name) {
|
||||
"ul", "ol" -> {
|
||||
lists.removeLastOrNull()
|
||||
lineBreak()
|
||||
}
|
||||
"a" -> closeLink()
|
||||
else -> if (name in BLOCK_TAGS) lineBreak()
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeLink() {
|
||||
val (url, start) = openLink ?: return
|
||||
if (out.length > start) links += LinkRange(start, out.length, url)
|
||||
openLink = null
|
||||
}
|
||||
|
||||
private fun text(raw: String) {
|
||||
val decoded = decodeEntities(raw)
|
||||
if (preDepth > 0) {
|
||||
out.append(decoded.replace("\r\n", "\n"))
|
||||
return
|
||||
}
|
||||
for (c in decoded) {
|
||||
if (c == '\n' && keepNewlines) {
|
||||
newline()
|
||||
} else if (c == ' ' || c == '\n' || c == '\t' || c == '\r' || c == '\u000C') {
|
||||
if (out.isNotEmpty() && out.last() != ' ' && out.last() != '\n') out.append(' ')
|
||||
} else {
|
||||
if (cellNeedsSeparator && c.isContent()) separateCell()
|
||||
out.append(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Put this cell's text beside the row's last, even after a block in that cell ended its line. */
|
||||
private fun separateCell() {
|
||||
cellNeedsSeparator = false
|
||||
var end = out.length
|
||||
while (end > rowStart && !out[end - 1].isContent()) end--
|
||||
cut(end)
|
||||
out.append(CELL_SEPARATOR)
|
||||
openStyles.replaceAll { (name, start) -> name to if (start >= end) out.length else start }
|
||||
openLink = openLink?.let { (url, start) -> url to if (start >= end) out.length else start }
|
||||
}
|
||||
|
||||
private fun Char.isContent() = !isWhitespace() && this != NBSP
|
||||
|
||||
private fun newline() {
|
||||
trimTrailingSpaces()
|
||||
out.append('\n')
|
||||
}
|
||||
|
||||
/** End the current line, unless it's already ended or holds just a bullet. */
|
||||
private fun lineBreak() {
|
||||
if (out.length == markerEnd) return
|
||||
trimTrailingSpaces()
|
||||
if (out.isNotEmpty() && out.last() != '\n') out.append('\n')
|
||||
}
|
||||
|
||||
private fun trimTrailingSpaces() {
|
||||
while (out.isNotEmpty() && out.last() == ' ') out.setLength(out.length - 1)
|
||||
}
|
||||
|
||||
/** Drop the output from [length] on, with whatever formatting lay only there. */
|
||||
private fun cut(length: Int) {
|
||||
if (length >= out.length) return
|
||||
out.setLength(length)
|
||||
styles.replaceAll { it.copy(end = minOf(it.end, length)) }
|
||||
styles.removeAll { it.end <= it.start }
|
||||
links.replaceAll { it.copy(end = minOf(it.end, length)) }
|
||||
links.removeAll { it.end <= it.start }
|
||||
openStyles.replaceAll { (name, start) -> name to minOf(start, length) }
|
||||
openLink = openLink?.let { (url, start) -> url to minOf(start, length) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Tidy the rendered text: no-break spaces become plain, lines lose trailing
|
||||
* blanks, runs of empty lines shrink to one and the ends are trimmed. Spans
|
||||
* move with the text they cover.
|
||||
*/
|
||||
private fun normalize(): RichText {
|
||||
closeLink()
|
||||
for ((name, start) in openStyles) {
|
||||
if (out.length > start) styles += StyleRange(start, out.length, STYLE_TAGS.getValue(name))
|
||||
}
|
||||
val src = out.toString().replace(NBSP, ' ')
|
||||
val map = IntArray(src.length + 1)
|
||||
val result = StringBuilder()
|
||||
var i = 0
|
||||
while (i < src.length) {
|
||||
var lineEnd = src.indexOf('\n', i)
|
||||
if (lineEnd < 0) lineEnd = src.length
|
||||
var contentEnd = lineEnd
|
||||
while (contentEnd > i && src[contentEnd - 1].isWhitespace()) contentEnd--
|
||||
for (j in i until lineEnd) {
|
||||
map[j] = result.length
|
||||
if (j < contentEnd) result.append(src[j])
|
||||
}
|
||||
if (lineEnd < src.length) {
|
||||
map[lineEnd] = result.length
|
||||
if (!result.endsWith("\n\n")) result.append('\n')
|
||||
}
|
||||
i = lineEnd + 1
|
||||
}
|
||||
map[src.length] = result.length
|
||||
val leading = result.length - result.trimStart().length
|
||||
val text = result.trim().toString()
|
||||
fun at(index: Int) = (map[index] - leading).coerceIn(0, text.length)
|
||||
return RichText(
|
||||
text = text,
|
||||
styles = styles.mapNotNull { r -> StyleRange(at(r.start), at(r.end), r.style).takeIf { it.end > it.start } },
|
||||
links = links.mapNotNull { r -> LinkRange(at(r.start), at(r.end), r.url).takeIf { it.end > it.start } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val startRegex = Regex("""(?:^|\s)start\s*=\s*["']?(-?\d{1,6})""", RegexOption.IGNORE_CASE)
|
||||
private val hrefRegex = Regex("""(?:^|\s)href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))""", RegexOption.IGNORE_CASE)
|
||||
|
||||
// Latin-1 ( … ÿ) in code point order, then the common extras.
|
||||
private val LATIN1_ENTITIES = ("nbsp iexcl cent pound curren yen brvbar sect uml copy ordf laquo not shy reg macr " +
|
||||
"deg plusmn sup2 sup3 acute micro para middot cedil sup1 ordm raquo frac14 frac12 frac34 iquest " +
|
||||
"Agrave Aacute Acirc Atilde Auml Aring AElig Ccedil Egrave Eacute Ecirc Euml Igrave Iacute Icirc Iuml " +
|
||||
"ETH Ntilde Ograve Oacute Ocirc Otilde Ouml times Oslash Ugrave Uacute Ucirc Uuml Yacute THORN szlig " +
|
||||
"agrave aacute acirc atilde auml aring aelig ccedil egrave eacute ecirc euml igrave iacute icirc iuml " +
|
||||
"eth ntilde ograve oacute ocirc otilde ouml divide oslash ugrave uacute ucirc uuml yacute thorn yuml").split(' ')
|
||||
|
||||
private val ENTITIES: Map<String, String> = LATIN1_ENTITIES.withIndex()
|
||||
.associate { (i, name) -> name to (0xA0 + i).toChar().toString() } + mapOf(
|
||||
"amp" to "&", "lt" to "<", "gt" to ">", "quot" to "\"", "apos" to "'",
|
||||
"ndash" to "–", "mdash" to "—", "hellip" to "…", "bull" to "•",
|
||||
"lsquo" to "‘", "rsquo" to "’", "ldquo" to "“", "rdquo" to "”", "sbquo" to "‚", "bdquo" to "„",
|
||||
"euro" to "€", "trade" to "™", "OElig" to "Œ", "oelig" to "œ", "Scaron" to "Š", "scaron" to "š",
|
||||
"zwnj" to "\u200C", "zwj" to "\u200D", "ensp" to "\u2002", "emsp" to "\u2003", "thinsp" to "\u2009",
|
||||
)
|
||||
|
||||
// A named reference needs its ";": "?id=5®=eu" in a link is a query, not "®".
|
||||
private val entityRegex = Regex("""&(?:(#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6});?|([a-zA-Z][a-zA-Z0-9]{1,31});)""")
|
||||
|
||||
/** Decode character references; an unknown name is left as written. */
|
||||
internal fun decodeEntities(text: String): String {
|
||||
if ('&' !in text) return text
|
||||
return entityRegex.replace(text) { match ->
|
||||
val name = match.groupValues[2]
|
||||
if (name.isNotEmpty()) return@replace ENTITIES[name] ?: match.value
|
||||
val body = match.groupValues[1]
|
||||
val code = when {
|
||||
body.startsWith("#x", ignoreCase = true) -> body.substring(2).toIntOrNull(16)
|
||||
else -> body.substring(1).toIntOrNull()
|
||||
}
|
||||
if (code == null || code == 0 || !Character.isValidCodePoint(code)) "�" else String(Character.toChars(code))
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Instant
|
||||
@@ -25,37 +21,27 @@ import kotlin.time.Instant
|
||||
* the extras lives in `MainActivity.insertFormOrNull`.
|
||||
*/
|
||||
fun buildInsertEventForm(
|
||||
beginMillis: Long?,
|
||||
endMillis: Long?,
|
||||
isAllDay: Boolean,
|
||||
beginMillis: Long? = null,
|
||||
endMillis: Long? = null,
|
||||
isAllDay: Boolean = false,
|
||||
title: String?,
|
||||
description: String?,
|
||||
location: String?,
|
||||
rrule: String?,
|
||||
location: String? = null,
|
||||
rrule: String? = null,
|
||||
zone: TimeZone,
|
||||
now: Instant,
|
||||
): EventForm {
|
||||
val (start, end) = if (isAllDay) {
|
||||
// All-day provider times are UTC midnights with an exclusive end; show
|
||||
// the last covered day and keep placeholder wall-clock times in case the
|
||||
// user switches the event to timed (mirrors EventDetail.toEditForm).
|
||||
val startDate = beginMillis
|
||||
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
|
||||
?: now.toLocalDateTime(zone).date
|
||||
val endDate = endMillis
|
||||
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
|
||||
?.let { exclusive -> maxOf(startDate, LocalDate.fromEpochDays(exclusive.toEpochDays() - 1)) }
|
||||
?: startDate
|
||||
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
||||
val endExclusive = endMillis?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
|
||||
allDayFormTimes(startDate, endExclusive)
|
||||
} else {
|
||||
val startTime = beginMillis
|
||||
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(zone) }
|
||||
?: nextFullHour(now, zone)
|
||||
val endTime = endMillis
|
||||
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(zone) }
|
||||
?.takeIf { it >= startTime }
|
||||
?: (startTime.toInstant(zone) + 1.hours).toLocalDateTime(zone)
|
||||
startTime to endTime
|
||||
val startAt = beginMillis?.let(Instant::fromEpochMilliseconds) ?: nextFullHour(now)
|
||||
val endAt = endMillis?.let(Instant::fromEpochMilliseconds)?.takeIf { it >= startAt }
|
||||
?: (startAt + 1.hours)
|
||||
startAt.toFormTime(zone) to endAt.toFormTime(zone)
|
||||
}
|
||||
return EventForm(
|
||||
calendarId = null,
|
||||
@@ -67,12 +53,38 @@ fun buildInsertEventForm(
|
||||
description = description.orEmpty(),
|
||||
// Bare RRULE value (Events.RRULE convention); tolerate a leading "RRULE:"
|
||||
// some callers include.
|
||||
rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
||||
rrule = rrule.normalizedRrule(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun nextFullHour(now: Instant, zone: TimeZone): LocalDateTime {
|
||||
/** The first full hour after [now]: may roll into the next day before midnight. */
|
||||
internal fun nextFullHour(now: Instant): Instant {
|
||||
val hourMillis = 3_600_000L
|
||||
val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis
|
||||
return Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone)
|
||||
return Instant.fromEpochMilliseconds((now.toEpochMilliseconds() / hourMillis + 1) * hourMillis)
|
||||
}
|
||||
|
||||
/** Title and description for text shared or selected into the app (#306). */
|
||||
data class SharedText(val title: String, val description: String)
|
||||
|
||||
/**
|
||||
* Split shared text into an event title and description: the share's subject
|
||||
* becomes the title and the text the description. Without a subject the text's
|
||||
* first non-blank line is the title and the rest the description. A title longer
|
||||
* than [maxTitle] is cut, and the full text then stays in the description.
|
||||
* Null when there is nothing to use.
|
||||
*/
|
||||
fun sharedTextOrNull(subject: String?, text: String?, maxTitle: Int = 255): SharedText? {
|
||||
val body = text?.trim().orEmpty()
|
||||
val given = subject?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val (title, rest) = if (given != null) {
|
||||
given to body
|
||||
} else {
|
||||
val lines = body.lines()
|
||||
val first = lines.firstOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return null
|
||||
first to lines.drop(1).joinToString("\n").trim()
|
||||
}
|
||||
if (title.length <= maxTitle) return SharedText(title, rest)
|
||||
val cut = title.take(maxTitle).let { if (it.last().isHighSurrogate()) it.dropLast(1) else it }
|
||||
val full = if (given != null) listOf(given, body).filter { it.isNotEmpty() }.joinToString("\n\n") else body
|
||||
return SharedText(cut.trimEnd(), full)
|
||||
}
|
||||
|
||||
@@ -165,6 +165,17 @@ data class EventDetail(
|
||||
val isException: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* This event on one occurrence's times. The Events row holds the series start,
|
||||
* so a recurring event needs the tapped occurrence pinned to render and share right.
|
||||
*/
|
||||
fun EventDetail.atOccurrence(beginMillis: Long, endMillis: Long): EventDetail = copy(
|
||||
instance = instance.copy(
|
||||
start = Instant.fromEpochMilliseconds(beginMillis),
|
||||
end = Instant.fromEpochMilliseconds(endMillis),
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* One selectable event colour published by a calendar's account
|
||||
* (`CalendarContract.Colors`, `TYPE_EVENT`): [key] is the account-scoped
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.ics.parseBasicDate
|
||||
import de.jeanlucmakiola.calendula.domain.ics.parseBasicDateTime
|
||||
import de.jeanlucmakiola.calendula.domain.ics.toUtcBasic
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
@@ -57,24 +60,13 @@ fun parseSimpleRecurrence(
|
||||
rrule: String,
|
||||
zone: TimeZone = TimeZone.currentSystemDefault(),
|
||||
): SimpleRecurrence? {
|
||||
val parts = rrule.removePrefix("RRULE:").split(';')
|
||||
.filter { it.isNotBlank() }
|
||||
.associate { token ->
|
||||
val eq = token.indexOf('=')
|
||||
if (eq <= 0) return null
|
||||
token.substring(0, eq).uppercase() to token.substring(eq + 1)
|
||||
}
|
||||
val parts = rruleParts(rrule).associate { part -> (part.key ?: return null) to part.value }
|
||||
if (parts.keys.any { it !in setOf("FREQ", "INTERVAL", "UNTIL", "COUNT", "WKST", "BYDAY") }) {
|
||||
return null
|
||||
}
|
||||
|
||||
val freq = when (parts["FREQ"]?.uppercase()) {
|
||||
"DAILY" -> RecurrenceFreq.Daily
|
||||
"WEEKLY" -> RecurrenceFreq.Weekly
|
||||
"MONTHLY" -> RecurrenceFreq.Monthly
|
||||
"YEARLY" -> RecurrenceFreq.Yearly
|
||||
else -> return null
|
||||
}
|
||||
val freq = RecurrenceFreq.entries.firstOrNull { it.name.equals(parts["FREQ"], ignoreCase = true) }
|
||||
?: return null
|
||||
val interval = parts["INTERVAL"]?.let { it.toIntOrNull()?.takeIf { n -> n >= 1 } ?: return null } ?: 1
|
||||
|
||||
// BYDAY is simple only as plain weekday picks on a weekly rule; ordinal
|
||||
@@ -124,20 +116,27 @@ fun SimpleRecurrence.toRRule(zone: TimeZone = TimeZone.currentSystemDefault()):
|
||||
when (val e = end) {
|
||||
RecurrenceEnd.Never -> Unit
|
||||
is RecurrenceEnd.Until -> {
|
||||
val utc = LocalDateTime(e.date, LocalTime(23, 59, 59))
|
||||
.toInstant(zone)
|
||||
.toLocalDateTime(TimeZone.UTC)
|
||||
append(
|
||||
";UNTIL=%04d%02d%02dT%02d%02d%02dZ".format(
|
||||
utc.year, utc.month.number, utc.day,
|
||||
utc.hour, utc.minute, utc.second,
|
||||
),
|
||||
)
|
||||
append(";UNTIL=")
|
||||
append(LocalDateTime(e.date, LocalTime(23, 59, 59)).toInstant(zone).toUtcBasic())
|
||||
}
|
||||
is RecurrenceEnd.Count -> append(";COUNT=${e.times}")
|
||||
}
|
||||
}
|
||||
|
||||
/** One `;`-separated part of an RRULE as written; [key] is null when it has no `KEY=`. */
|
||||
internal class RrulePart(val raw: String) {
|
||||
private val eq = raw.indexOf('=')
|
||||
val key: String? = if (eq > 0) raw.substring(0, eq).uppercase() else null
|
||||
val value: String = if (eq > 0) raw.substring(eq + 1) else ""
|
||||
}
|
||||
|
||||
/**
|
||||
* [rule]'s non-blank parts, after an optional leading "RRULE:". What to do
|
||||
* with a malformed one (reject, repair or skip) is the caller's call.
|
||||
*/
|
||||
internal fun rruleParts(rule: String): List<RrulePart> =
|
||||
rule.removePrefix("RRULE:").split(';').filter { it.isNotBlank() }.map(::RrulePart)
|
||||
|
||||
internal val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
|
||||
DayOfWeek.MONDAY to "MO",
|
||||
DayOfWeek.TUESDAY to "TU",
|
||||
@@ -165,11 +164,7 @@ fun rruleTruncatedAt(rrule: String, untilUtcMillis: Long): String {
|
||||
val key = part.substringBefore('=').trim().uppercase()
|
||||
key == "UNTIL" || key == "COUNT"
|
||||
}
|
||||
val until = Instant.fromEpochMilliseconds(untilUtcMillis).toLocalDateTime(TimeZone.UTC)
|
||||
val untilPart = "UNTIL=%04d%02d%02dT%02d%02d%02dZ".format(
|
||||
until.year, until.month.number, until.day,
|
||||
until.hour, until.minute, until.second,
|
||||
)
|
||||
val untilPart = "UNTIL=" + Instant.fromEpochMilliseconds(untilUtcMillis).toUtcBasic()
|
||||
return (kept + untilPart).joinToString(";")
|
||||
}
|
||||
|
||||
@@ -178,20 +173,8 @@ fun rruleTruncatedAt(rrule: String, untilUtcMillis: Long): String {
|
||||
* forms are UTC (RFC 5545); the date is taken after converting into [zone] so
|
||||
* a [toRRule]-rendered value round-trips to the day the user picked.
|
||||
*/
|
||||
private fun parseUntilDate(raw: String, zone: TimeZone): LocalDate? = runCatching {
|
||||
val date = LocalDate(
|
||||
raw.substring(0, 4).toInt(),
|
||||
raw.substring(4, 6).toInt(),
|
||||
raw.substring(6, 8).toInt(),
|
||||
)
|
||||
if (raw.length >= 15 && raw[8] == 'T') {
|
||||
val time = LocalTime(
|
||||
raw.substring(9, 11).toInt(),
|
||||
raw.substring(11, 13).toInt(),
|
||||
raw.substring(13, 15).toInt(),
|
||||
)
|
||||
LocalDateTime(date, time).toInstant(TimeZone.UTC).toLocalDateTime(zone).date
|
||||
} else {
|
||||
date
|
||||
}
|
||||
}.getOrNull()
|
||||
private fun parseUntilDate(raw: String, zone: TimeZone): LocalDate? {
|
||||
val date = parseBasicDate(raw) ?: return null
|
||||
if (raw.length < 15 || raw[8] != 'T') return date
|
||||
return parseBasicDateTime(raw)?.toInstant(TimeZone.UTC)?.toLocalDateTime(zone)?.date
|
||||
}
|
||||
|
||||
@@ -19,30 +19,30 @@ import kotlinx.datetime.LocalDate
|
||||
fun realignRecurrence(rrule: String, oldStart: LocalDate, newStart: LocalDate): String? {
|
||||
if (oldStart == newStart) return rrule
|
||||
val prefix = if (rrule.startsWith("RRULE:")) "RRULE:" else ""
|
||||
val parts = rrule.removePrefix("RRULE:").split(';').filter { it.isNotBlank() }
|
||||
val parts = rruleParts(rrule)
|
||||
if (parts.isEmpty()) return null
|
||||
var freq = false
|
||||
var weekly = false
|
||||
var byDay = false
|
||||
val rebuilt = parts.map { part ->
|
||||
val eq = part.indexOf('=')
|
||||
if (eq <= 0) return null
|
||||
val key = part.substring(0, eq).uppercase()
|
||||
val value = part.substring(eq + 1).trim()
|
||||
when (key) {
|
||||
val value = part.value.trim()
|
||||
when (part.key) {
|
||||
"FREQ" -> {
|
||||
freq = true
|
||||
weekly = value.equals("WEEKLY", ignoreCase = true)
|
||||
part
|
||||
part.raw
|
||||
}
|
||||
"BYDAY" -> {
|
||||
byDay = true
|
||||
val old = RRULE_DAY_CODES[oldStart.dayOfWeek] ?: return null
|
||||
if (!value.equals(old, ignoreCase = true)) return null
|
||||
"BYDAY=${RRULE_DAY_CODES.getValue(newStart.dayOfWeek)}"
|
||||
}
|
||||
"INTERVAL", "COUNT", "UNTIL", "WKST" -> part
|
||||
"INTERVAL", "COUNT", "UNTIL", "WKST" -> part.raw
|
||||
else -> return null
|
||||
}
|
||||
}
|
||||
// BYDAY is only simple on a weekly rule, matching parseSimpleRecurrence.
|
||||
if (!weekly && parts.any { it.substringBefore('=').trim().uppercase() == "BYDAY" }) return null
|
||||
if (parts.none { it.substringBefore('=').trim().uppercase() == "FREQ" }) return null
|
||||
if (!freq || (byDay && !weekly)) return null
|
||||
return prefix + rebuilt.joinToString(";")
|
||||
}
|
||||
|
||||
@@ -157,14 +157,36 @@ fun timeZoneOptionOf(
|
||||
* the offset isn't stated twice in ICU's spelling and ours.
|
||||
*/
|
||||
fun zoneDescriptor(option: TimeZoneOption): String {
|
||||
val abbrev = option.shortName
|
||||
return when {
|
||||
abbrev == "UTC" -> "UTC"
|
||||
abbrev.startsWith("GMT") -> formatGmtOffset(option.offsetMinutes)
|
||||
else -> "$abbrev · ${formatGmtOffset(option.offsetMinutes)}"
|
||||
}
|
||||
val name = zoneAbbreviation(option)
|
||||
val statesOffset = option.shortName == "UTC" || option.shortName.startsWith("GMT")
|
||||
return if (statesOffset) name else "$name · ${formatGmtOffset(option.offsetMinutes)}"
|
||||
}
|
||||
|
||||
/**
|
||||
* [option]'s short name on its own — "CEST", "UTC" — with an offset-shaped one
|
||||
* normalised to our GMT form, as [zoneDescriptor] spells it.
|
||||
*/
|
||||
fun zoneAbbreviation(option: TimeZoneOption): String = abbreviation(option.shortName, option.offsetMinutes)
|
||||
|
||||
/**
|
||||
* Zone [id]'s abbreviation [at] an instant, as [zoneAbbreviation] spells it,
|
||||
* without the localized long name a whole [TimeZoneOption] would look up. Null
|
||||
* for an id the tz database can't parse.
|
||||
*/
|
||||
fun zoneAbbreviationAt(
|
||||
id: String,
|
||||
locale: Locale,
|
||||
at: Instant,
|
||||
regionOf: (String) -> String? = { null },
|
||||
): String? {
|
||||
val zone = runCatching { ZoneId.of(id) }.getOrNull() ?: return null
|
||||
val shortName = resolveAbbreviation(zone.id, zone.rules.isDaylightSavings(at), locale, regionOf)
|
||||
return abbreviation(shortName, zone.rules.getOffset(at).totalSeconds / 60)
|
||||
}
|
||||
|
||||
private fun abbreviation(shortName: String, offsetMinutes: Int): String =
|
||||
if (shortName.startsWith("GMT")) formatGmtOffset(offsetMinutes) else shortName
|
||||
|
||||
/**
|
||||
* [options] matching [query], best matches first; a blank query returns the list
|
||||
* unchanged. Matching is accent- and case-insensitive and treats underscores as
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.format
|
||||
import kotlinx.datetime.format.char
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** RFC 5545 basic format, `yyyyMMddTHHmmss`. */
|
||||
private val BASIC_DATE_TIME = LocalDateTime.Format {
|
||||
date(LocalDate.Formats.ISO_BASIC)
|
||||
char('T')
|
||||
hour()
|
||||
minute()
|
||||
second()
|
||||
}
|
||||
|
||||
/** `yyyyMMdd`. */
|
||||
internal fun LocalDate.toBasic(): String = format(LocalDate.Formats.ISO_BASIC)
|
||||
|
||||
/** `yyyyMMddTHHmmss`, floating. */
|
||||
internal fun LocalDateTime.toBasic(): String = format(BASIC_DATE_TIME)
|
||||
|
||||
/** `yyyyMMddTHHmmssZ`, the instant in UTC. */
|
||||
internal fun Instant.toUtcBasic(): String = toLocalDateTime(TimeZone.UTC).toBasic() + "Z"
|
||||
|
||||
/**
|
||||
* The date in the first eight characters of a basic-format value; whatever
|
||||
* follows (a time, a `Z`) is ignored. Null when those aren't a valid date.
|
||||
*/
|
||||
internal fun parseBasicDate(s: String): LocalDate? = runCatching {
|
||||
LocalDate(s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt())
|
||||
}.getOrNull()
|
||||
|
||||
/** `yyyyMMddTHHmm[ss]`; the separator isn't checked and missing seconds read as zero. */
|
||||
internal fun parseBasicDateTime(s: String): LocalDateTime? = runCatching {
|
||||
val date = parseBasicDate(s) ?: return null
|
||||
val time = LocalTime(
|
||||
s.substring(9, 11).toInt(),
|
||||
s.substring(11, 13).toInt(),
|
||||
if (s.length >= 15) s.substring(13, 15).toInt() else 0,
|
||||
)
|
||||
LocalDateTime(date, time)
|
||||
}.getOrNull()
|
||||
@@ -2,19 +2,17 @@ package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import kotlinx.datetime.LocalDate
|
||||
import de.jeanlucmakiola.calendula.domain.zoneOrNull
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.number
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.math.ceil
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Milliseconds in a calendar day; all-day times are UTC midnights, so this is exact. */
|
||||
private const val DAY_MILLIS = 86_400_000L
|
||||
private const val MINUTES_PER_DAY = 1_440
|
||||
|
||||
/**
|
||||
@@ -318,13 +316,9 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
dtEnd: IcsDateTime?,
|
||||
duration: String?,
|
||||
): Instant {
|
||||
val explicit = dtEnd?.instant ?: duration?.let {
|
||||
Instant.fromEpochMilliseconds(
|
||||
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
|
||||
)
|
||||
}
|
||||
val explicit = dtEnd?.instant ?: duration?.let { start.instant + parseRfc2445DurationMillis(it).milliseconds }
|
||||
if (!start.isAllDay) return explicit ?: start.instant
|
||||
return explicit?.takeIf { it > start.instant } ?: start.instant.plusDays(1)
|
||||
return explicit?.takeIf { it > start.instant } ?: (start.instant + 1.days)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -343,17 +337,17 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
*/
|
||||
private fun normalizeExDates(lines: List<IcsContentLine>, start: IcsDateTime): List<String> {
|
||||
if (lines.isEmpty()) return emptyList()
|
||||
val startZone = runCatching { TimeZone.of(start.zoneId) }.getOrNull() ?: deviceZone
|
||||
val startZone = zoneOrNull(start.zoneId) ?: deviceZone
|
||||
val startTime = start.instant.toLocalDateTime(startZone).time
|
||||
return lines
|
||||
.flatMap { line -> line.value.split(',').map { line to it.trim() } }
|
||||
.mapNotNull { (line, token) ->
|
||||
when {
|
||||
token.isEmpty() -> null
|
||||
start.isAllDay -> parseBasicDate(token.substringBefore('T'))?.let(::dayCode)
|
||||
token.contains('T') -> parseExDateTime(token, line, startZone)?.let(::utcStamp)
|
||||
start.isAllDay -> parseBasicDate(token.substringBefore('T'))?.toBasic()
|
||||
token.contains('T') -> parseExDateTime(token, line, startZone)?.toUtcBasic()
|
||||
else -> parseBasicDate(token)
|
||||
?.let { utcStamp(LocalDateTime(it, startTime).toInstant(startZone)) }
|
||||
?.let { LocalDateTime(it, startTime).toInstant(startZone).toUtcBasic() }
|
||||
}
|
||||
}
|
||||
.distinct()
|
||||
@@ -373,7 +367,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
val ldt = parseBasicDateTime(token.removeSuffix("Z")) ?: return null
|
||||
val zone = when {
|
||||
token.endsWith("Z") -> TimeZone.UTC
|
||||
else -> line.params["TZID"]?.let { runCatching { TimeZone.of(it) }.getOrNull() }
|
||||
else -> line.params["TZID"]?.let(::zoneOrNull)
|
||||
?: startZone
|
||||
}
|
||||
return ldt.toInstant(zone)
|
||||
@@ -409,7 +403,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
if (isUtc) return IcsDateTime(ldt.toInstant(TimeZone.UTC), isAllDay = false, zoneId = "UTC")
|
||||
|
||||
val tzid = line.params["TZID"]
|
||||
val resolved = tzid?.let { id -> runCatching { TimeZone.of(id) }.getOrNull()?.let { id to it } }
|
||||
val resolved = tzid?.let { id -> zoneOrNull(id)?.let { id to it } }
|
||||
if (tzid != null && resolved == null) warnings.add(IcsParseWarning.UnknownTimezone)
|
||||
val (zoneId, zone) = resolved ?: (deviceZone.id to deviceZone)
|
||||
return IcsDateTime(ldt.toInstant(zone), isAllDay = false, zoneId = zoneId)
|
||||
@@ -421,16 +415,6 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
fun IcsContentLine.isBegin(component: String) =
|
||||
name == "BEGIN" && value.trim().equals(component, true)
|
||||
|
||||
fun Instant.plusDays(days: Int): Instant =
|
||||
Instant.fromEpochMilliseconds(toEpochMilliseconds() + days * DAY_MILLIS)
|
||||
|
||||
fun dayCode(date: LocalDate): String =
|
||||
"%04d%02d%02d".format(date.year, date.month.number, date.day)
|
||||
|
||||
fun utcStamp(instant: Instant): String = with(instant.toLocalDateTime(TimeZone.UTC)) {
|
||||
"%04d%02d%02dT%02d%02d%02dZ".format(year, month.number, day, hour, minute, second)
|
||||
}
|
||||
|
||||
/**
|
||||
* A single-valued `CATEGORIES`, which is what the Fossify family writes
|
||||
* for the source calendar's name. A multi-item list is a tag list and
|
||||
@@ -449,59 +433,26 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
|
||||
.takeIf { it.isNotEmpty() && !it.equals("null", true) }
|
||||
}
|
||||
|
||||
/** Index of the matching `END:<component>` at/after [from], or list end. */
|
||||
/**
|
||||
* Where an unterminated component's body has to stop: the next top-level
|
||||
* `VEVENT` / `VTODO` in `[from, end)`, or [end] when there is none. A
|
||||
* nested `VALARM` is part of the body and is not a boundary.
|
||||
*/
|
||||
fun indexOfNextComponent(lines: List<String>, from: Int, end: Int): Int {
|
||||
var i = from
|
||||
while (i < end) {
|
||||
fun indexOfNextComponent(lines: List<String>, from: Int, end: Int): Int =
|
||||
(from until end).firstOrNull { i ->
|
||||
val line = parseContentLine(lines[i])
|
||||
if (line != null && (line.isBegin("VEVENT") || line.isBegin("VTODO"))) {
|
||||
return i
|
||||
}
|
||||
i++
|
||||
}
|
||||
return end
|
||||
}
|
||||
line != null && (line.isBegin("VEVENT") || line.isBegin("VTODO"))
|
||||
} ?: end
|
||||
|
||||
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
|
||||
var i = from
|
||||
while (i < lines.size) {
|
||||
/** Index of the matching `END:<component>` at/after [from], or list end. */
|
||||
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int =
|
||||
(from until lines.size).firstOrNull { i ->
|
||||
val line = parseContentLine(lines[i])
|
||||
if (line != null && line.name == "END" &&
|
||||
line.value.trim().equals(component, true)
|
||||
) {
|
||||
return i
|
||||
}
|
||||
i++
|
||||
}
|
||||
return lines.size
|
||||
}
|
||||
line != null && line.name == "END" && line.value.trim().equals(component, true)
|
||||
} ?: lines.size
|
||||
|
||||
fun mapIcsStatus(value: String): EventStatus = when (value.trim().uppercase()) {
|
||||
"TENTATIVE" -> EventStatus.Tentative
|
||||
"CANCELLED" -> EventStatus.Cancelled
|
||||
else -> EventStatus.Confirmed
|
||||
}
|
||||
|
||||
fun parseBasicDate(s: String): LocalDate? = runCatching {
|
||||
LocalDate(s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt())
|
||||
}.getOrNull()
|
||||
|
||||
fun parseBasicDateTime(s: String): LocalDateTime? = runCatching {
|
||||
val date = LocalDate(
|
||||
s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt(),
|
||||
)
|
||||
// Format is YYYYMMDD 'T' HHMMSS; seconds optional.
|
||||
val time = LocalTime(
|
||||
s.substring(9, 11).toInt(),
|
||||
s.substring(11, 13).toInt(),
|
||||
if (s.length >= 15) s.substring(13, 15).toInt() else 0,
|
||||
)
|
||||
LocalDateTime(date, time)
|
||||
}.getOrNull()
|
||||
fun mapIcsStatus(value: String): EventStatus =
|
||||
EventStatus.entries.firstOrNull { it.name.equals(value.trim(), ignoreCase = true) }
|
||||
?: EventStatus.Confirmed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.rruleParts
|
||||
|
||||
private val VALID_FREQ = setOf(
|
||||
"SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY",
|
||||
)
|
||||
@@ -79,13 +81,12 @@ fun sanitizeRrule(raw: String?): SanitizedRrule {
|
||||
return kept.takeIf { it.isNotEmpty() }?.joinToString(",")
|
||||
}
|
||||
|
||||
for (part in rule.split(';')) {
|
||||
// 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.
|
||||
if (part.isBlank()) continue
|
||||
val key = part.substringBefore('=', "").trim().uppercase()
|
||||
val value = if ('=' in part) part.substringAfter('=').trim() else ""
|
||||
// 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(raw.orEmpty().trim())) {
|
||||
val key = part.key?.trim().orEmpty()
|
||||
val value = part.value.trim()
|
||||
val kept = when {
|
||||
key.isEmpty() || value.isEmpty() -> null
|
||||
key == "FREQ" ->
|
||||
|
||||
@@ -2,9 +2,8 @@ package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import de.jeanlucmakiola.calendula.domain.zoneOrNull
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.number
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
@@ -41,7 +40,7 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
|
||||
private fun MutableList<String>.appendEvent(event: IcsEvent, dtStamp: Instant) {
|
||||
add("BEGIN:VEVENT")
|
||||
add("UID:${event.uid}")
|
||||
add("DTSTAMP:${utcStamp(dtStamp)}")
|
||||
add("DTSTAMP:${dtStamp.toUtcBasic()}")
|
||||
add("SUMMARY:${escapeText(event.summary)}")
|
||||
appendTimes(event)
|
||||
event.recurrenceRule?.takeIf { it.isNotBlank() }
|
||||
@@ -63,24 +62,24 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
|
||||
|
||||
private fun MutableList<String>.appendTimes(event: IcsEvent) = when {
|
||||
event.isAllDay -> {
|
||||
add("DTSTART;VALUE=DATE:${utcDate(event.start)}")
|
||||
add("DTEND;VALUE=DATE:${utcDate(event.end)}")
|
||||
add("DTSTART;VALUE=DATE:${event.start.toLocalDateTime(TimeZone.UTC).date.toBasic()}")
|
||||
add("DTEND;VALUE=DATE:${event.end.toLocalDateTime(TimeZone.UTC).date.toBasic()}")
|
||||
}
|
||||
// Recurring: anchor to wall-clock in the event's own zone.
|
||||
event.recurrenceRule?.isNotBlank() == true -> {
|
||||
val zone = runCatching { TimeZone.of(event.zoneId) }.getOrNull()
|
||||
val zone = zoneOrNull(event.zoneId)
|
||||
if (zone != null) {
|
||||
add("DTSTART;TZID=${event.zoneId}:${localStamp(event.start, zone)}")
|
||||
add("DTEND;TZID=${event.zoneId}:${localStamp(event.end, zone)}")
|
||||
add("DTSTART;TZID=${event.zoneId}:${event.start.toLocalDateTime(zone).toBasic()}")
|
||||
add("DTEND;TZID=${event.zoneId}:${event.end.toLocalDateTime(zone).toBasic()}")
|
||||
} else {
|
||||
// Unknown zone id → fall back to plain UTC instants.
|
||||
add("DTSTART:${utcStamp(event.start)}")
|
||||
add("DTEND:${utcStamp(event.end)}")
|
||||
add("DTSTART:${event.start.toUtcBasic()}")
|
||||
add("DTEND:${event.end.toUtcBasic()}")
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
add("DTSTART:${utcStamp(event.start)}")
|
||||
add("DTEND:${utcStamp(event.end)}")
|
||||
add("DTSTART:${event.start.toUtcBasic()}")
|
||||
add("DTEND:${event.end.toUtcBasic()}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,20 +119,5 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
|
||||
// A lead time of 0 fires at start (PT0M); anything positive is "before".
|
||||
fun triggerValue(minutes: Int): String =
|
||||
if (minutes <= 0) "PT0M" else "-PT${minutes}M"
|
||||
|
||||
fun utcStamp(instant: Instant): String =
|
||||
basic(instant.toLocalDateTime(TimeZone.UTC)) + "Z"
|
||||
|
||||
fun localStamp(instant: Instant, zone: TimeZone): String =
|
||||
basic(instant.toLocalDateTime(zone))
|
||||
|
||||
fun utcDate(instant: Instant): String {
|
||||
val dt = instant.toLocalDateTime(TimeZone.UTC)
|
||||
return "%04d%02d%02d".format(dt.year, dt.month.number, dt.day)
|
||||
}
|
||||
|
||||
fun basic(dt: LocalDateTime): String = "%04d%02d%02dT%02d%02d%02d".format(
|
||||
dt.year, dt.month.number, dt.day, dt.hour, dt.minute, dt.second,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import de.jeanlucmakiola.calendula.domain.allDayFormTimes
|
||||
import de.jeanlucmakiola.calendula.domain.normalizedRrule
|
||||
import de.jeanlucmakiola.calendula.domain.toFormTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
|
||||
@@ -21,12 +21,9 @@ import kotlinx.datetime.toLocalDateTime
|
||||
*/
|
||||
fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
||||
val (start, end) = if (isAllDay) {
|
||||
val startDate = this.start.toLocalDateTime(TimeZone.UTC).date
|
||||
val endExclusive = this.end.toLocalDateTime(TimeZone.UTC).date
|
||||
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
|
||||
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
||||
allDayFormTimes(this.start.toLocalDateTime(TimeZone.UTC).date, this.end.toLocalDateTime(TimeZone.UTC).date)
|
||||
} else {
|
||||
this.start.toLocalDateTime(zone) to this.end.toLocalDateTime(zone)
|
||||
this.start.toFormTime(zone) to this.end.toFormTime(zone)
|
||||
}
|
||||
return EventForm(
|
||||
calendarId = null,
|
||||
@@ -38,6 +35,6 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
|
||||
description = description.orEmpty(),
|
||||
reminders = semanticReminderMinutes(),
|
||||
availability = availability,
|
||||
rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
||||
rrule = recurrenceRule.normalizedRrule(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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.SeekableTransitionState
|
||||
@@ -19,6 +20,7 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
@@ -27,8 +29,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.screen
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewScreen
|
||||
@@ -37,6 +41,7 @@ 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
|
||||
@@ -55,7 +60,13 @@ import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBackExit
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventActionsViewModel
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
|
||||
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.rememberWriteGateOr
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
||||
@@ -76,6 +87,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.selectView
|
||||
import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
|
||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
||||
import de.jeanlucmakiola.calendula.ui.edit.EventEditViewModel
|
||||
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
||||
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
|
||||
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
|
||||
@@ -90,7 +102,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
|
||||
|
||||
/**
|
||||
* Holds the top-level view back stack (spec M1) and swaps between the calendar
|
||||
@@ -106,28 +118,18 @@ import kotlin.time.Clock
|
||||
* 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.
|
||||
*
|
||||
* [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.)
|
||||
* [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.)
|
||||
*/
|
||||
@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 = {},
|
||||
requestedEditKey: LongArray? = null,
|
||||
onEditKeyConsumed: () -> Unit = {},
|
||||
viewModel: CalendarHostViewModel = hiltViewModel(),
|
||||
) {
|
||||
// Wait for the persisted default view before seeding the stack, so the app
|
||||
@@ -146,10 +148,13 @@ fun CalendarHost(
|
||||
mutableStateOf(listOf(defaultView))
|
||||
}
|
||||
var navigationVersion by remember { mutableIntStateOf(0) }
|
||||
// True after backing out of a day opened from the view now shown, so the
|
||||
// agenda returns to where it was instead of today (#367).
|
||||
var returnedFromDay by remember { mutableStateOf(false) }
|
||||
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(localToday()) }
|
||||
val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(currentDate()) }
|
||||
// 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
|
||||
@@ -163,6 +168,7 @@ fun CalendarHost(
|
||||
val switchScope = rememberCoroutineScope()
|
||||
val switchTo: ((List<CalendarView>) -> List<CalendarView>) -> Unit = { move ->
|
||||
navigationVersion++
|
||||
returnedFromDay = false
|
||||
viewStack = move(viewStack)
|
||||
}
|
||||
val onSelectView: (CalendarView) -> Unit = { target -> switchTo { it.selectView(target) } }
|
||||
@@ -179,27 +185,17 @@ 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.
|
||||
// [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.
|
||||
// Like every overlay below, it draws its last key through the slide-out
|
||||
// ([rememberHeld]) rather than a placeholder.
|
||||
var detailKey by rememberSaveable { mutableStateOf<LongArray?>(null) }
|
||||
var heldKey by remember { mutableStateOf<LongArray?>(null) }
|
||||
val onEventClick: (EventInstance) -> Unit = { event ->
|
||||
val key = longArrayOf(
|
||||
event.eventId,
|
||||
event.start.toEpochMilliseconds(),
|
||||
event.end.toEpochMilliseconds(),
|
||||
)
|
||||
heldKey = key
|
||||
detailKey = key
|
||||
}
|
||||
val onOpenDetail: (EventInstance) -> Unit = { event -> detailKey = event.occurrenceKey() }
|
||||
|
||||
// A tapped reminder notification asks for a specific occurrence.
|
||||
val requestedDetailKey = requests.detailKey
|
||||
LaunchedEffect(requestedDetailKey) {
|
||||
if (requestedDetailKey != null) {
|
||||
heldKey = requestedDetailKey
|
||||
detailKey = requestedDetailKey
|
||||
onDetailKeyConsumed()
|
||||
requests.detailKey = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,19 +219,17 @@ fun CalendarHost(
|
||||
// calendar list rather than by preferences. Reached from both.
|
||||
var showBackup by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// 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) }
|
||||
val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes ->
|
||||
heldCreateIso = date.toString()
|
||||
createDateIso = date.toString()
|
||||
heldCreateMinutes = startMinutes
|
||||
createStartMinutes = startMinutes
|
||||
// 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)
|
||||
}
|
||||
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 onCreateDraft: (Instant, Instant) -> Unit = { start, end ->
|
||||
val date = start.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
openCreate(date.toString(), draftMillis = longArrayOf(start.toEpochMilliseconds(), end.toEpochMilliseconds()))
|
||||
}
|
||||
|
||||
// Edit form (v1.3) — reuses the detail screen's occurrence key; for
|
||||
@@ -245,7 +239,6 @@ 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
|
||||
@@ -255,6 +248,34 @@ fun CalendarHost(
|
||||
// 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 remember { mutableStateOf(ImportSource.File) }
|
||||
// Bumped per open, so even an equal prefill starts a fresh form.
|
||||
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 importViewModel: EventEditViewModel = hiltViewModel(key = "import")
|
||||
fun openImportForm(form: EventForm, source: ImportSource, fromRequest: Boolean = false) {
|
||||
importFormSource = source
|
||||
importForm = form
|
||||
importFormId++
|
||||
importFromRequest = fromRequest
|
||||
}
|
||||
fun closeImportForm() {
|
||||
val next = pendingInsert
|
||||
pendingInsert = null
|
||||
if (next != null) {
|
||||
openImportForm(next.first, next.second, fromRequest = true)
|
||||
} else {
|
||||
importForm = null
|
||||
if (importFromRequest) {
|
||||
importFromRequest = false
|
||||
requests.onExternalFormClosed()
|
||||
}
|
||||
}
|
||||
}
|
||||
// A restore (in-app "Restore from .ics" button) always runs the full import
|
||||
// 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
|
||||
@@ -270,23 +291,28 @@ 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) }
|
||||
val requestedImportUri = requests.importUri
|
||||
LaunchedEffect(requestedImportUri) {
|
||||
if (requestedImportUri != null) {
|
||||
importUri = requestedImportUri
|
||||
importForceMany = false
|
||||
importSession++
|
||||
onImportConsumed()
|
||||
requests.importUri = null
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
LaunchedEffect(requestedInsertForm) {
|
||||
if (requestedInsertForm != null) {
|
||||
importFormSource = requestedInsertSource
|
||||
importForm = requestedInsertForm
|
||||
onInsertConsumed()
|
||||
val requestedInsert = requests.insert
|
||||
LaunchedEffect(requestedInsert) {
|
||||
if (requestedInsert != null) {
|
||||
if (importForm == null) {
|
||||
openImportForm(requestedInsert.form, requestedInsert.source, fromRequest = true)
|
||||
} else {
|
||||
pendingInsert = requestedInsert.form to requestedInsert.source
|
||||
}
|
||||
requests.insert = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,50 +327,55 @@ fun CalendarHost(
|
||||
editKey = null
|
||||
importUri = null
|
||||
importForm = null
|
||||
importFromRequest = false
|
||||
pendingInsert = null
|
||||
importViewModel.reset()
|
||||
}
|
||||
|
||||
// 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. Same held-key pattern as
|
||||
// a detail-screen "Edit" tap; a saved edit just returns to the calendar.
|
||||
// without this it would open hidden underneath them. A saved edit just
|
||||
// returns to the calendar.
|
||||
val requestedEditKey = requests.editKey
|
||||
LaunchedEffect(requestedEditKey) {
|
||||
if (requestedEditKey != null) {
|
||||
dismissCoveringOverlays()
|
||||
heldEditKey = requestedEditKey
|
||||
editKey = requestedEditKey
|
||||
onEditKeyConsumed()
|
||||
requests.editKey = null
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
|
||||
// the detail request. 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++
|
||||
if (widgetNavRequest != null) {
|
||||
navigationVersion++
|
||||
returnedFromDay = false
|
||||
}
|
||||
when (val req = widgetNavRequest) {
|
||||
is WidgetNavRequest.OpenDate -> {
|
||||
// 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
|
||||
createRequest = null
|
||||
focus.navigateTo(LocalDate.parse(req.dateIso))
|
||||
// No widget source (an external date tap) roots over the default
|
||||
// home view, so backing out of the day returns home then exits.
|
||||
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay()
|
||||
onWidgetNavConsumed()
|
||||
requests.nav = null
|
||||
}
|
||||
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
|
||||
createRequest = null
|
||||
viewStack = viewBaseStack(defaultView, req.source)
|
||||
val key = longArrayOf(req.eventId, req.beginMillis, req.endMillis)
|
||||
heldKey = key
|
||||
detailKey = key
|
||||
onWidgetNavConsumed()
|
||||
detailKey = longArrayOf(req.eventId, req.beginMillis, req.endMillis)
|
||||
requests.nav = null
|
||||
}
|
||||
is WidgetNavRequest.OpenView -> {
|
||||
// A widget header tap: land on a top-level view with no date
|
||||
@@ -352,10 +383,10 @@ 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()
|
||||
createDateIso = null
|
||||
focus.navigateTo(localToday())
|
||||
createRequest = null
|
||||
focus.navigateTo(currentDate())
|
||||
viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
|
||||
onWidgetNavConsumed()
|
||||
requests.nav = null
|
||||
}
|
||||
is WidgetNavRequest.Create -> {
|
||||
// External "new event" entries (QS tile / launcher shortcut /
|
||||
@@ -363,13 +394,8 @@ fun CalendarHost(
|
||||
// sits below Settings/calendars in the Box, so without this it
|
||||
// would open hidden underneath them.
|
||||
dismissCoveringOverlays()
|
||||
val iso = req.dateIso ?: Clock.System.now()
|
||||
.toLocalDateTime(TimeZone.currentSystemDefault()).date.toString()
|
||||
heldCreateIso = iso
|
||||
createDateIso = iso
|
||||
heldCreateMinutes = null
|
||||
createStartMinutes = null
|
||||
onWidgetNavConsumed()
|
||||
openCreate(req.dateIso ?: currentDate().toString())
|
||||
requests.nav = null
|
||||
}
|
||||
null -> {}
|
||||
}
|
||||
@@ -379,14 +405,31 @@ fun CalendarHost(
|
||||
// survives view switches; the calendar surfaces read it out of LocalEventMove.
|
||||
val reschedule: RescheduleViewModel = hiltViewModel()
|
||||
val movableCalendarIds by reschedule.movableCalendarIds.collectAsStateWithLifecycle()
|
||||
val onEditEvent: (EventInstance) -> Unit = { event ->
|
||||
val key = longArrayOf(
|
||||
event.eventId,
|
||||
event.start.toEpochMilliseconds(),
|
||||
event.end.toEpochMilliseconds(),
|
||||
)
|
||||
heldEditKey = key
|
||||
editKey = key
|
||||
val eventActions: EventActionsViewModel = hiltViewModel()
|
||||
val moveOutcome by reschedule.outcome.collectAsStateWithLifecycle()
|
||||
val selectionFailure by eventActions.failure.collectAsStateWithLifecycle()
|
||||
val onEditEvent: (EventInstance) -> Unit = { event -> editKey = event.occurrenceKey() }
|
||||
// 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) }
|
||||
val onEventClick: (EventInstance) -> Unit = { event ->
|
||||
tapScope.launch {
|
||||
val toEditor = viewModel.tapOpensEditor() && eventActions.canModify(event.calendarId)
|
||||
when {
|
||||
!toEditor -> onOpenDetail(event)
|
||||
tapContext.canWriteCalendar() -> onEditEvent(event)
|
||||
tapWriteRefused -> onOpenDetail(event)
|
||||
else -> requireWriteOr({ onEditEvent(event) }) {
|
||||
tapWriteRefused = true
|
||||
onOpenDetail(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Off by preference (#173) switches off the gesture, not the scope: blocks
|
||||
// register no drag, while the edit form and its TalkBack action stay put.
|
||||
@@ -403,15 +446,10 @@ 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 || createDateIso != null ||
|
||||
val anyOverlayVisible = showSearch || detailKey != null || createRequest != null ||
|
||||
editKey != null || showSettings || showCalendars || showBackup ||
|
||||
importUri != null || importForm != null
|
||||
// The view switch runs on a seekable transition, so a back gesture can scrub
|
||||
@@ -424,11 +462,14 @@ fun CalendarHost(
|
||||
if (reduceMotion) {
|
||||
BackHandler(enabled = viewBackEnabled) {
|
||||
navigationVersion++
|
||||
returnedFromDay = viewStack.last() == CalendarView.Day
|
||||
viewStack = viewStack.dropLast(1)
|
||||
}
|
||||
} else {
|
||||
PredictiveBackHandler(enabled = viewBackEnabled) { events ->
|
||||
val previous = viewStack[viewStack.size - 2]
|
||||
// Set before the seek, which already composes the previous view.
|
||||
returnedFromDay = viewStack.last() == CalendarView.Day
|
||||
try {
|
||||
events.collect { viewTransition.seekTo(it.progress, targetState = previous) }
|
||||
navigationVersion++
|
||||
@@ -517,7 +558,24 @@ fun CalendarHost(
|
||||
showTodayButton = todayInToolbar,
|
||||
)
|
||||
},
|
||||
floatingActionButton = { CalendarFab(chromeHost, todayInToolbar) },
|
||||
floatingActionButton = {
|
||||
// While an event is selected the FAB is its action bar's toggle (#141).
|
||||
val fabSelection = rememberFabSelection(
|
||||
host = chromeHost,
|
||||
viewModel = eventActions,
|
||||
onEdit = onEditEvent,
|
||||
onDuplicate = { form ->
|
||||
// A fresh event, like a duplicate from the detail screen (#52).
|
||||
openImportForm(form, ImportSource.Insert)
|
||||
},
|
||||
chipShown = moveOutcome != null || selectionFailure != null,
|
||||
onDismissChip = {
|
||||
reschedule.consumeOutcome()
|
||||
eventActions.consumeFailure()
|
||||
},
|
||||
)
|
||||
CalendarFab(chromeHost, todayInToolbar, fabSelection)
|
||||
},
|
||||
) { innerPadding ->
|
||||
SharedTransitionLayout(
|
||||
Modifier
|
||||
@@ -548,6 +606,7 @@ fun CalendarHost(
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = onCreateEvent,
|
||||
onCreateDraft = onCreateDraft,
|
||||
)
|
||||
ViewScreen.Month -> MonthScreen(
|
||||
onOpenDay = onOpenDay,
|
||||
@@ -560,6 +619,7 @@ fun CalendarHost(
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
restorePosition = returnedFromDay,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -571,15 +631,19 @@ fun CalendarHost(
|
||||
|
||||
// Scope prompt + confirmation/undo snackbar for a dropped event, declared
|
||||
// right after the calendar views so later overlays cover it.
|
||||
EventMoveHost(reschedule, modifier = Modifier.fillMaxSize())
|
||||
EventMoveHost(
|
||||
reschedule,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
actionFailure = eventActionFailureMessage(selectionFailure),
|
||||
onActionFailureShown = eventActions::consumeFailure,
|
||||
)
|
||||
|
||||
// The delete prompt and failure chip of a selected event's actions (#141).
|
||||
EventSelectionHost(host = chromeHost, viewModel = eventActions)
|
||||
|
||||
// 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.
|
||||
AnimatedVisibility(
|
||||
visible = showSearch,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
SlideOverlay(visible = showSearch) {
|
||||
SearchScreen(
|
||||
onBack = { showSearch = false },
|
||||
onEventClick = onEventClick,
|
||||
@@ -587,29 +651,20 @@ fun CalendarHost(
|
||||
)
|
||||
}
|
||||
|
||||
// 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 ->
|
||||
val shownDetailKey = rememberHeld(detailKey)
|
||||
SlideOverlay(visible = detailKey != null) {
|
||||
shownDetailKey?.let { key ->
|
||||
EventDetailScreen(
|
||||
eventId = key[0],
|
||||
beginMillis = key[1],
|
||||
endMillis = key[2],
|
||||
onBack = { detailKey = null },
|
||||
onEdit = {
|
||||
heldEditKey = key
|
||||
editKey = key
|
||||
},
|
||||
onEdit = { editKey = key },
|
||||
onDuplicate = { form ->
|
||||
// Reuse the prefilled-create overlay: a duplicate is a
|
||||
// fresh event, so it applies the default reminder like an
|
||||
// in-app new event (#52).
|
||||
importFormSource = ImportSource.Insert
|
||||
importForm = form
|
||||
openImportForm(form, ImportSource.Insert)
|
||||
detailKey = null
|
||||
},
|
||||
visible = detailKey != null,
|
||||
@@ -618,30 +673,26 @@ fun CalendarHost(
|
||||
}
|
||||
|
||||
// Event form (v1.2) — full-screen destination, slides over the calendar.
|
||||
AnimatedVisibility(
|
||||
visible = createDateIso != null,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
(createDateIso ?: heldCreateIso)?.let { iso ->
|
||||
val shownCreate = rememberHeld(createRequest)
|
||||
SlideOverlay(visible = createRequest != null) {
|
||||
shownCreate?.let { create ->
|
||||
EventEditScreen(
|
||||
initialDateIso = iso,
|
||||
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
|
||||
onClose = { createDateIso = null },
|
||||
onSaved = { createDateIso = null },
|
||||
initialDateIso = create.dateIso,
|
||||
initialStartMinutes = create.startMinutes,
|
||||
initialDraft = create.draftMillis,
|
||||
onClose = { createRequest = null },
|
||||
onSaved = { createRequest = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
visible = createDateIso != null,
|
||||
visible = createRequest != null,
|
||||
viewModel = hiltViewModel(key = "create"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Edit form (v1.3) — slides over the detail screen.
|
||||
AnimatedVisibility(
|
||||
visible = editKey != null,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
(editKey ?: heldEditKey)?.let { key ->
|
||||
val shownEditKey = rememberHeld(editKey)
|
||||
SlideOverlay(visible = editKey != null) {
|
||||
shownEditKey?.let { key ->
|
||||
EventEditScreen(
|
||||
initialDateIso = null,
|
||||
editKey = key,
|
||||
@@ -651,17 +702,23 @@ fun CalendarHost(
|
||||
detailKey = null
|
||||
},
|
||||
onManageCalendars = { showCalendars = true },
|
||||
onDuplicate = { form ->
|
||||
openImportForm(form, ImportSource.Insert)
|
||||
},
|
||||
// Opened straight from a tap (#178) there's no detail screen
|
||||
// below to show why, so open it.
|
||||
onLoadFailed = {
|
||||
editKey = null
|
||||
if (detailKey == null) detailKey = key
|
||||
},
|
||||
visible = editKey != null,
|
||||
viewModel = hiltViewModel(key = "edit"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Settings (M4) — full-screen destination, slides over the calendar.
|
||||
AnimatedVisibility(
|
||||
visible = showSettings,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
SlideOverlay(visible = showSettings) {
|
||||
SettingsScreen(
|
||||
onBack = { showSettings = false },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
@@ -697,29 +754,29 @@ fun CalendarHost(
|
||||
importUri = null
|
||||
calendarsAfterImport = false
|
||||
backupAfterImport = false
|
||||
importFormSource = ImportSource.File
|
||||
importForm = form
|
||||
openImportForm(form, ImportSource.File)
|
||||
},
|
||||
)
|
||||
}
|
||||
importForm?.let { form ->
|
||||
EventEditScreen(
|
||||
initialDateIso = null,
|
||||
initialForm = form,
|
||||
initialFormSource = importFormSource,
|
||||
onClose = { importForm = null },
|
||||
onSaved = { importForm = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
)
|
||||
key(importFormId) {
|
||||
EventEditScreen(
|
||||
initialDateIso = null,
|
||||
initialForm = form,
|
||||
initialFormSource = importFormSource,
|
||||
onClose = ::closeImportForm,
|
||||
onSaved = ::closeImportForm,
|
||||
onManageCalendars = { showCalendars = true },
|
||||
leaveRequested = pendingInsert != null,
|
||||
onLeaveDeclined = { pendingInsert = null },
|
||||
viewModel = importViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Declared last so it covers every overlay that can open it: Settings,
|
||||
// both event forms, and the .ics import picker (#76).
|
||||
AnimatedVisibility(
|
||||
visible = showCalendars,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
SlideOverlay(visible = showCalendars) {
|
||||
CalendarsScreen(
|
||||
onBack = { showCalendars = false },
|
||||
onOpenBackup = { showBackup = true },
|
||||
@@ -728,11 +785,7 @@ fun CalendarHost(
|
||||
}
|
||||
|
||||
// Backup & restore — over the manager, since the manager links into it.
|
||||
AnimatedVisibility(
|
||||
visible = showBackup,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
SlideOverlay(visible = showBackup) {
|
||||
// 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
|
||||
@@ -771,5 +824,56 @@ private val viewStackSaver = listSaver<List<CalendarView>, Int>(
|
||||
restore = { ordinals -> ordinals.map { CalendarView.entries[it] } },
|
||||
)
|
||||
|
||||
private fun localToday(): LocalDate =
|
||||
Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
/**
|
||||
* 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, content: @Composable AnimatedVisibilityScope.() -> Unit) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = slideInHorizontally(rememberCalendarSlideSpec()) { it } + fadeIn(),
|
||||
exit = predictiveBackExit(),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* [live], or after it goes null the last value it had, so an overlay keeps
|
||||
* drawing what it showed through its slide-out instead of flashing empty. Never
|
||||
* a placeholder: until [live] is first set this is null, so a destination never
|
||||
* loads a bogus key on its first frame. Not saved — after a recreation the
|
||||
* saveable [live] is the value again.
|
||||
*/
|
||||
@Composable
|
||||
private fun <T : Any> rememberHeld(live: T?): T? {
|
||||
val held = remember { HeldValue<T>() }
|
||||
if (live != null) held.value = live
|
||||
return live ?: held.value
|
||||
}
|
||||
|
||||
private class HeldValue<T : Any> {
|
||||
var value: T? = null
|
||||
}
|
||||
|
||||
/** The detail and edit overlays' key for one occurrence: eventId, begin, end. */
|
||||
private fun EventInstance.occurrenceKey(): LongArray =
|
||||
longArrayOf(eventId, start.toEpochMilliseconds(), end.toEpochMilliseconds())
|
||||
|
||||
/**
|
||||
* 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?) },
|
||||
)
|
||||
|
||||
@@ -8,10 +8,10 @@ import de.jeanlucmakiola.calendula.data.time.TodaySource
|
||||
import de.jeanlucmakiola.calendula.data.time.currentDate
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.datetime.LocalDate
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -28,40 +28,35 @@ class CalendarHostViewModel @Inject constructor(
|
||||
) : ViewModel() {
|
||||
|
||||
/** Today, kept current across midnight so the shared focus can follow it (#373). */
|
||||
val today: StateFlow<LocalDate> = todaySource.today.stateIn(
|
||||
val today: StateFlow<LocalDate> = todaySource.today.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = currentDate(),
|
||||
)
|
||||
|
||||
val defaultView: StateFlow<CalendarView?> = prefs.defaultView.stateIn(
|
||||
val defaultView: StateFlow<CalendarView?> = prefs.defaultView.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
/** Views the top-bar quick-switch pill cycles through, in the user's order (#24). */
|
||||
val quickSwitchViews: StateFlow<List<CalendarView>> = prefs.quickSwitchConfig
|
||||
.map { it.cycle }
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = IMPLEMENTED_VIEWS,
|
||||
)
|
||||
|
||||
/** Order of the views in the navigation drawer (#24); every view always shown. */
|
||||
val drawerViewOrder: StateFlow<List<CalendarView>> = prefs.drawerViewOrder
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = IMPLEMENTED_VIEWS,
|
||||
)
|
||||
|
||||
/** Whether each view's jump-to-today control lives in the top bar, not the FAB (#60). */
|
||||
val todayButtonInToolbar: StateFlow<Boolean> = prefs.todayButtonInToolbar
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
@@ -73,9 +68,13 @@ class CalendarHostViewModel @Inject constructor(
|
||||
* switched off to be rid of.
|
||||
*/
|
||||
val dragToReschedule: StateFlow<Boolean> = prefs.dragToReschedule
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
private val tapOpensEditorPref = prefs.tapOpensEditor
|
||||
|
||||
/** Whether tapping an event opens the edit form rather than the detail screen (#178). */
|
||||
suspend fun tapOpensEditor(): Boolean = tapOpensEditorPref.first()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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)
|
||||
}
|
||||
|
||||
/** A prefilled create form from an external launch, with the source it came from. */
|
||||
data class InsertRequest(val form: EventForm, val source: ImportSource)
|
||||
@@ -1,7 +1,5 @@
|
||||
package de.jeanlucmakiola.calendula.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
@@ -20,11 +18,11 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.core.content.ContextCompat
|
||||
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
|
||||
@@ -43,26 +41,12 @@ 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 = {},
|
||||
requestedEditKey: LongArray? = null,
|
||||
onEditKeyConsumed: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var hasPermission by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR)
|
||||
== PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
mutableStateOf(context.canReadCalendar())
|
||||
}
|
||||
|
||||
// A launch scan already covers the app coming up with the permission, so
|
||||
@@ -73,9 +57,7 @@ fun RootScreen(
|
||||
DisposableEffect(lifecycle) {
|
||||
val obs = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
hasPermission = ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.READ_CALENDAR
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
hasPermission = context.canReadCalendar()
|
||||
// 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.
|
||||
@@ -145,18 +127,8 @@ 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,
|
||||
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.
|
||||
|
||||
@@ -3,8 +3,9 @@ package de.jeanlucmakiola.calendula.ui.agenda
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import java.time.YearMonth
|
||||
import java.util.Locale
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.yearMonth
|
||||
|
||||
/**
|
||||
* How far ahead the agenda (screen or widget) shows events, starting at today.
|
||||
@@ -50,7 +51,7 @@ fun AgendaRange.dayCount(anchor: LocalDate, weekStart: DayOfWeek): Int = when (t
|
||||
DAYS_PER_WEEK - sinceWeekStart
|
||||
}
|
||||
AgendaRange.ThisMonth -> {
|
||||
val daysInMonth = YearMonth.of(anchor.year, anchor.month.ordinal + 1).lengthOfMonth()
|
||||
val daysInMonth = anchor.yearMonth.numberOfDays
|
||||
daysInMonth - anchor.day + 1
|
||||
}
|
||||
is AgendaRange.Custom -> days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS)
|
||||
@@ -105,8 +106,8 @@ fun agendaRangeWindowSummary(
|
||||
locale: Locale,
|
||||
monthAsSpan: Boolean = false,
|
||||
): String {
|
||||
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day)
|
||||
val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day)
|
||||
val javaStart = start.toJavaLocalDate()
|
||||
val javaEnd = end.toJavaLocalDate()
|
||||
val dayMonth = localizedDateFormatter(locale, "dMMM")
|
||||
val dayMonthYear = localizedDateFormatter(locale, "dMMMy")
|
||||
return when {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -10,6 +11,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Coffee
|
||||
@@ -31,22 +34,25 @@ import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
|
||||
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.morphEvent
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import java.util.Locale
|
||||
|
||||
// The agenda's row vocabulary, split out of AgendaScreen so the month view's
|
||||
@@ -127,7 +133,7 @@ internal fun AgendaEventRow(
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val dark = LocalFloretDarkTheme.current
|
||||
val soften = LocalSoftenColors.current
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
GroupedRow(
|
||||
@@ -177,34 +183,74 @@ internal fun agendaTimeSummary(event: EventInstance, day: LocalDate, zone: TimeZ
|
||||
AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day)
|
||||
is AgendaTimeLabel.Starts -> stringResource(
|
||||
R.string.agenda_span_starts,
|
||||
formatTime(label.start, zone, is24Hour, locale),
|
||||
formatTimeOfDay(label.start, zone, is24Hour, locale),
|
||||
)
|
||||
is AgendaTimeLabel.Ends -> stringResource(
|
||||
R.string.agenda_span_ends,
|
||||
formatTime(label.end, zone, is24Hour, locale),
|
||||
formatTimeOfDay(label.end, zone, is24Hour, locale),
|
||||
)
|
||||
is AgendaTimeLabel.Range -> "${formatTime(label.start, zone, is24Hour, locale)} – " +
|
||||
formatTime(label.end, zone, is24Hour, locale)
|
||||
is AgendaTimeLabel.Range -> "${formatTimeOfDay(label.start, zone, is24Hour, locale)} – " +
|
||||
formatTimeOfDay(label.end, zone, is24Hour, locale)
|
||||
}
|
||||
|
||||
val location = event.location?.takeIf { it.isNotBlank() }
|
||||
return if (location != null) "$time · $location" else time
|
||||
}
|
||||
|
||||
private fun formatTime(
|
||||
instant: Instant,
|
||||
zone: TimeZone,
|
||||
is24Hour: Boolean,
|
||||
locale: Locale,
|
||||
): String {
|
||||
val t = instant.toLocalDateTime(zone).time
|
||||
return formatTimeOfDay(t.hour, t.minute, is24Hour, locale)
|
||||
}
|
||||
|
||||
private fun formatAgendaDate(date: LocalDate): String {
|
||||
val locale = Locale.getDefault()
|
||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||
val java = date.toJavaLocalDate()
|
||||
// Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026"
|
||||
// vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout.
|
||||
return localizedDateFormatter(locale, "EEEdMMMy").format(java)
|
||||
}
|
||||
|
||||
/**
|
||||
* The agenda's day-by-day list body: a sticky header per day, its events or a
|
||||
* placeholder row for an event-less day, then a gap. Shared by the Agenda screen
|
||||
* and the month view's split pane so both lists read the same.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
internal fun LazyListScope.agendaDays(
|
||||
days: List<AgendaDay>,
|
||||
today: LocalDate,
|
||||
zone: TimeZone,
|
||||
@StringRes emptyText: Int,
|
||||
onEmptyClick: (LocalDate) -> Unit,
|
||||
dimmed: (EventInstance, LocalDate) -> Boolean,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
) {
|
||||
days.forEach { day ->
|
||||
stickyHeader(key = "header-${day.date}") {
|
||||
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
|
||||
}
|
||||
if (day.events.isEmpty()) {
|
||||
item(key = "placeholder-${day.date}") {
|
||||
AgendaEmptyDayRow(
|
||||
text = stringResource(emptyText),
|
||||
onClick = { onEmptyClick(day.date) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
itemsIndexed(
|
||||
items = day.events,
|
||||
// Scope the key by day: a multi-day event appears under every
|
||||
// day it spans, so its instanceId alone is not unique across
|
||||
// the list (LazyColumn requires unique keys).
|
||||
key = { _, event -> "${day.date}-${event.instanceId}" },
|
||||
) { index, event ->
|
||||
AgendaEventRow(
|
||||
event = event,
|
||||
day = day.date,
|
||||
zone = zone,
|
||||
position = positionOf(index, day.events.size),
|
||||
dimmed = dimmed(event, day.date),
|
||||
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)),
|
||||
onClick = { onEventClick(event) },
|
||||
)
|
||||
}
|
||||
}
|
||||
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,15 +5,11 @@ import de.jeanlucmakiola.calendula.ui.common.ScreenActions
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScreenBody
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScreenChrome
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
|
||||
import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
|
||||
import de.jeanlucmakiola.calendula.ui.common.morphEvent
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -27,7 +23,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Coffee
|
||||
@@ -38,7 +33,16 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableIntState
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -53,16 +57,14 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.common.isPastOn
|
||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -82,11 +84,23 @@ fun AgendaScreen(
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
modifier: Modifier = Modifier,
|
||||
restorePosition: Boolean = false,
|
||||
viewModel: AgendaViewModel = hiltViewModel(),
|
||||
) {
|
||||
// Opens on the focused date and hands back the day scrolled to the top.
|
||||
// Opens on today, not on the day another view was on (#367). Backing out of
|
||||
// a day opened from here, or a recreated screen, returns to the day last at
|
||||
// the top. The top day still goes back to the shared focus.
|
||||
val focus = LocalViewFocus.current
|
||||
EnterOnFocus(viewModel) { if (it != viewModel.anchor.value) viewModel.goToDate(it) }
|
||||
val opened = rememberSaveable { mutableStateOf(false) }
|
||||
val restoreTop = remember(viewModel) {
|
||||
if (restorePosition || opened.value) {
|
||||
viewModel.lastTopDate
|
||||
} else {
|
||||
viewModel.goToToday()
|
||||
null
|
||||
}
|
||||
}
|
||||
SideEffect { opened.value = true }
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
||||
@@ -106,20 +120,19 @@ fun AgendaScreen(
|
||||
// list scroll, not an anchor change — "today" has to bring the list back to
|
||||
// the top as well, and stay offered while it is scrolled away (#305).
|
||||
val listState = rememberLazyListState()
|
||||
// Past days load above the anchor (#367), so the anchor's row counts as the
|
||||
// start of the list.
|
||||
val anchorIndex = remember { mutableIntStateOf(0) }
|
||||
val scrolledAway by remember {
|
||||
derivedStateOf {
|
||||
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
|
||||
listState.firstVisibleItemIndex != anchorIndex.intValue ||
|
||||
listState.firstVisibleItemScrollOffset > 0
|
||||
}
|
||||
}
|
||||
// A new window is rendered from its first day, so the list belongs at the
|
||||
// top. 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.
|
||||
LaunchedEffect(successState?.anchor) { listState.scrollToItem(0) }
|
||||
val jumpToToday: () -> Unit = {
|
||||
// Off today, moving the anchor is what resets the list (above); on today
|
||||
// Off today, moving the anchor is what repositions the list; on today
|
||||
// the window doesn't change, so the scroll back is the whole action.
|
||||
if (isOnToday) scope.launch { listState.animateScrollToItem(0) }
|
||||
if (isOnToday) scope.launch { listState.animateScrollToItem(anchorIndex.intValue) }
|
||||
viewModel.goToToday()
|
||||
(state as? AgendaUiState.Success)?.today?.let { focus?.date = it }
|
||||
}
|
||||
@@ -188,11 +201,17 @@ fun AgendaScreen(
|
||||
pastDisplay = pastDisplay,
|
||||
showToday = showToday,
|
||||
listState = listState,
|
||||
anchorIndex = anchorIndex,
|
||||
initialTopDate = restoreTop,
|
||||
onLoadEarlier = viewModel::loadEarlier,
|
||||
onRetry = viewModel::goToToday,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onTopDay = if (successState?.anchor == anchor) {
|
||||
{ focus?.date = it }
|
||||
{
|
||||
focus?.date = it
|
||||
viewModel.rememberTopDay(it)
|
||||
}
|
||||
} else null,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
@@ -294,6 +313,9 @@ internal fun AgendaContent(
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
listState: LazyListState = rememberLazyListState(),
|
||||
anchorIndex: MutableIntState = remember { mutableIntStateOf(0) },
|
||||
initialTopDate: LocalDate? = null,
|
||||
onLoadEarlier: () -> Unit = {},
|
||||
onTopDay: ((LocalDate) -> Unit)? = null,
|
||||
) {
|
||||
when (state) {
|
||||
@@ -308,28 +330,34 @@ internal fun AgendaContent(
|
||||
// away (or fade) as they end while the screen stays open.
|
||||
val filtered = if (pastDisplay == PastEventDisplay.HIDE) {
|
||||
state.days.mapNotNull { day ->
|
||||
val remaining = day.events.filterNot { it.hasEnded(now) }
|
||||
val remaining = day.events.filterNot { it.isPastOn(day.date, now, state.zone) }
|
||||
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
||||
}
|
||||
} else {
|
||||
state.days
|
||||
}
|
||||
// Anchor today with a "nothing left today" placeholder — but only when
|
||||
// the window actually starts on today; a jumped-to date has no today in
|
||||
// it, so anchoring there would be misleading (#35).
|
||||
// the agenda actually opens on today; a jumped-to date has no today
|
||||
// in it, so anchoring there would be misleading (#35).
|
||||
val days = anchorTodayIfMissing(
|
||||
days = filtered,
|
||||
today = state.today,
|
||||
enabled = showToday && state.anchor == state.today,
|
||||
)
|
||||
if (days.isEmpty()) {
|
||||
// Past days alone don't count as anything coming up.
|
||||
if (days.none { it.date >= state.anchor }) {
|
||||
// Nothing to scroll: drop the position a previous list left, so
|
||||
// the today FAB doesn't linger over an empty screen.
|
||||
LaunchedEffect(Unit) { listState.scrollToItem(0) }
|
||||
SideEffect { anchorIndex.intValue = 0 }
|
||||
AgendaEmpty(modifier)
|
||||
} else {
|
||||
AgendaList(
|
||||
days = days,
|
||||
anchor = state.anchor,
|
||||
anchorIndex = anchorIndex,
|
||||
initialTopDate = initialTopDate,
|
||||
onLoadEarlier = onLoadEarlier,
|
||||
today = state.today,
|
||||
zone = state.zone,
|
||||
listState = listState,
|
||||
@@ -345,10 +373,13 @@ internal fun AgendaContent(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun AgendaList(
|
||||
days: List<AgendaDay>,
|
||||
anchor: LocalDate,
|
||||
anchorIndex: MutableIntState,
|
||||
initialTopDate: LocalDate?,
|
||||
onLoadEarlier: () -> Unit,
|
||||
today: LocalDate,
|
||||
zone: TimeZone,
|
||||
listState: LazyListState,
|
||||
@@ -359,59 +390,70 @@ private fun AgendaList(
|
||||
onTopDay: ((LocalDate) -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val itemDates = agendaItemDates(days)
|
||||
val anchorRow = itemDates.indexOfFirst { it >= anchor }.coerceAtLeast(0)
|
||||
SideEffect { anchorIndex.intValue = anchorRow }
|
||||
// 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]
|
||||
pendingRestore[0] = null
|
||||
val index = target?.let { date -> itemDates.indexOfFirst { it >= date } }
|
||||
?.takeIf { it >= 0 }
|
||||
?: anchorRow
|
||||
listState.requestScrollToItem(index)
|
||||
onDispose {}
|
||||
}
|
||||
// Scrolling up near the first loaded day loads earlier days.
|
||||
val currentLoadEarlier by rememberUpdatedState(onLoadEarlier)
|
||||
val loadOnScrollUp = remember(listState) {
|
||||
object : NestedScrollConnection {
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
if (available.y > 0 && listState.firstVisibleItemIndex < LOAD_EARLIER_WITHIN) {
|
||||
currentLoadEarlier()
|
||||
}
|
||||
return Offset.Zero
|
||||
}
|
||||
}
|
||||
}
|
||||
if (onTopDay != null) {
|
||||
val itemDates by rememberUpdatedState(agendaItemDates(days))
|
||||
val currentItemDates by rememberUpdatedState(itemDates)
|
||||
val currentOnTopDay by rememberUpdatedState(onTopDay)
|
||||
// The day the list opened on is where the view already is; only a
|
||||
// scroll moves it on.
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow { listState.firstVisibleItemIndex }
|
||||
.drop(1)
|
||||
.collect { index -> itemDates.getOrNull(index)?.let(currentOnTopDay) }
|
||||
.collect { index -> currentItemDates.getOrNull(index)?.let(currentOnTopDay) }
|
||||
}
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
modifier = modifier.nestedScroll(loadOnScrollUp),
|
||||
state = listState,
|
||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||
) {
|
||||
days.forEach { day ->
|
||||
stickyHeader(key = "header-${day.date}") {
|
||||
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
|
||||
}
|
||||
if (day.events.isEmpty()) {
|
||||
// An anchored, event-less today (#35) — "nothing left today".
|
||||
item(key = "placeholder-${day.date}") {
|
||||
AgendaEmptyDayRow(
|
||||
text = stringResource(R.string.agenda_no_more_today),
|
||||
onClick = { onOpenDay(day.date) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
itemsIndexed(
|
||||
items = day.events,
|
||||
// Scope the key by day: a multi-day event appears under every
|
||||
// day it spans, so its instanceId alone is not unique across
|
||||
// the list (LazyColumn requires unique keys).
|
||||
key = { _, event -> "${day.date}-${event.instanceId}" },
|
||||
) { index, event ->
|
||||
AgendaEventRow(
|
||||
event = event,
|
||||
day = day.date,
|
||||
zone = zone,
|
||||
position = positionOf(index, day.events.size),
|
||||
dimmed = dimPast && event.hasEnded(now),
|
||||
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)),
|
||||
onClick = { onEventClick(event) },
|
||||
)
|
||||
}
|
||||
}
|
||||
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
agendaDays(
|
||||
days = days,
|
||||
today = today,
|
||||
zone = zone,
|
||||
// An anchored, event-less today (#35) — "nothing left today".
|
||||
emptyText = R.string.agenda_no_more_today,
|
||||
onEmptyClick = onOpenDay,
|
||||
dimmed = { event, day -> dimPast && event.isPastOn(day, now, zone) },
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** How close to the first loaded row an upward scroll starts loading earlier days. */
|
||||
private const val LOAD_EARLIER_WITHIN = 10
|
||||
|
||||
/** The day of each item [AgendaList] lays out for [days], in its order. */
|
||||
internal fun agendaItemDates(days: List<AgendaDay>): List<LocalDate> =
|
||||
days.flatMap { day ->
|
||||
|
||||
@@ -93,22 +93,21 @@ fun groupAgendaDays(
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure [today] surfaces as the first agenda day even when it carries no
|
||||
* (remaining) events, by prepending an empty-event [AgendaDay] the agenda widget
|
||||
* Ensure [today] surfaces in the agenda even when it carries no (remaining)
|
||||
* events, by inserting an empty-event [AgendaDay] in date order that the agenda
|
||||
* renders as a "nothing left today" placeholder. A no-op unless [enabled], and
|
||||
* when today already has its own day in [days]. Keeps today anchored at the top
|
||||
* so a glance tells today's events apart from a future day's (issue #35).
|
||||
* when today already has its own day in [days]. Keeps today anchored so a glance
|
||||
* tells today's events apart from a future day's (issue #35).
|
||||
*/
|
||||
fun anchorTodayIfMissing(
|
||||
days: List<AgendaDay>,
|
||||
today: LocalDate,
|
||||
enabled: Boolean,
|
||||
): List<AgendaDay> =
|
||||
if (enabled && days.none { it.date == today }) {
|
||||
listOf(AgendaDay(today, emptyList())) + days
|
||||
} else {
|
||||
days
|
||||
}
|
||||
): List<AgendaDay> {
|
||||
if (!enabled || days.any { it.date == today }) return days
|
||||
val at = days.indexOfFirst { it.date > today }.let { if (it < 0) days.size else it }
|
||||
return days.take(at) + AgendaDay(today, emptyList()) + days.drop(at)
|
||||
}
|
||||
|
||||
/**
|
||||
* State for the Agenda view: a flat, forward-looking list of upcoming events
|
||||
@@ -118,8 +117,10 @@ sealed interface AgendaUiState {
|
||||
data object Loading : AgendaUiState
|
||||
data class Failure(val reason: FailureReason) : AgendaUiState
|
||||
data class Success(
|
||||
/** First day of the loaded window (today, or a jumped-to date). */
|
||||
/** The day the agenda opens on (today, or a jumped-to date). */
|
||||
val anchor: LocalDate,
|
||||
/** First loaded day, before [anchor] once past days are loaded (#367). */
|
||||
val windowStart: LocalDate,
|
||||
val today: LocalDate,
|
||||
val days: List<AgendaDay>,
|
||||
/** The range currently in effect — the saved default or a session override. */
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import de.jeanlucmakiola.calendula.ui.common.dayRange
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
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
|
||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -23,16 +26,14 @@ import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -64,9 +65,8 @@ class AgendaViewModel @Inject constructor(
|
||||
* the screen combines it with a per-minute "now" to fade or drop past rows.
|
||||
*/
|
||||
val pastEventDisplay: StateFlow<PastEventDisplay> = settingsPrefs.pastEventDisplay
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = PastEventDisplay.SHOW,
|
||||
)
|
||||
|
||||
@@ -77,9 +77,8 @@ class AgendaViewModel @Inject constructor(
|
||||
* alongside the data state like [pastEventDisplay] rather than re-querying.
|
||||
*/
|
||||
val showToday: StateFlow<Boolean> = settingsPrefs.agendaShowToday
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = true,
|
||||
)
|
||||
|
||||
@@ -88,22 +87,25 @@ class AgendaViewModel @Inject constructor(
|
||||
private val todayDate: LocalDate
|
||||
get() = currentDate()
|
||||
|
||||
private val _anchor = MutableStateFlow(todayDate)
|
||||
val anchor: StateFlow<LocalDate> = _anchor
|
||||
// The anchor and how many days before it are loaded, moved together so a
|
||||
// jump never queries a window that pairs the new anchor with the old span.
|
||||
private val _window = MutableStateFlow(AgendaWindow(todayDate, PAST_CHUNK_DAYS))
|
||||
val anchor: StateFlow<LocalDate> = _window
|
||||
.map { it.anchor }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, todayDate)
|
||||
|
||||
private var lastToday = todayDate
|
||||
/** The day last scrolled to the top, so a return to the agenda lands back on it. */
|
||||
var lastTopDate: LocalDate? = null
|
||||
private set
|
||||
|
||||
/**
|
||||
* 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
|
||||
.onEach { date ->
|
||||
val old = lastToday
|
||||
lastToday = date
|
||||
if (old != date && _anchor.value == old) _anchor.value = date
|
||||
private val today: StateFlow<LocalDate> =
|
||||
todaySource.today.stateFollowingDays(viewModelScope, todayDate) { old, date ->
|
||||
if (_window.value.anchor == old) _window.value = _window.value.copy(anchor = date)
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), 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
|
||||
@@ -111,9 +113,17 @@ class AgendaViewModel @Inject constructor(
|
||||
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
|
||||
|
||||
val state: StateFlow<AgendaUiState> =
|
||||
combine(_anchor, agendaSettings, _rangeOverride, weekStart) { anchor, settings, override, weekStart ->
|
||||
combine(
|
||||
_window,
|
||||
agendaSettings,
|
||||
_rangeOverride,
|
||||
weekStart,
|
||||
settingsPrefs.pastEventDisplay,
|
||||
) { window, settings, override, weekStart, pastDisplay ->
|
||||
AgendaParams(
|
||||
anchor = anchor,
|
||||
anchor = window.anchor,
|
||||
// Hidden past events leave nothing to scroll back to (#367).
|
||||
pastDays = if (pastDisplay == PastEventDisplay.HIDE) 0 else window.pastDays,
|
||||
range = override ?: settings.range,
|
||||
rangeIsOverride = override != null && override != settings.range,
|
||||
weekStart = weekStart,
|
||||
@@ -122,8 +132,8 @@ class AgendaViewModel @Inject constructor(
|
||||
}
|
||||
.flatMapLatest { params ->
|
||||
val window = agendaRange(
|
||||
params.anchor,
|
||||
params.range.dayCount(params.anchor, params.weekStart) - 1,
|
||||
params.windowStart,
|
||||
params.pastDays + params.range.dayCount(params.anchor, params.weekStart) - 1,
|
||||
zone,
|
||||
)
|
||||
combine(
|
||||
@@ -136,19 +146,36 @@ class AgendaViewModel @Inject constructor(
|
||||
}
|
||||
.catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = AgendaUiState.Loading,
|
||||
)
|
||||
|
||||
fun goToToday() {
|
||||
_anchor.value = todayDate
|
||||
goToDate(todayDate)
|
||||
}
|
||||
|
||||
/** Jump the agenda window to start on a specific date (drawer jump-to-date). */
|
||||
fun goToDate(date: LocalDate) {
|
||||
_anchor.value = date
|
||||
if (_window.value.anchor == date) return
|
||||
_window.value = AgendaWindow(date, PAST_CHUNK_DAYS)
|
||||
lastTopDate = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
_window.value = window.copy(pastDays = window.pastDays + PAST_CHUNK_DAYS)
|
||||
}
|
||||
|
||||
fun rememberTopDay(date: LocalDate) {
|
||||
lastTopDate = date
|
||||
}
|
||||
|
||||
/** Temporarily override the agenda range for this session (the bottom-left pill). */
|
||||
@@ -161,13 +188,20 @@ class AgendaViewModel @Inject constructor(
|
||||
val showBar: Boolean,
|
||||
)
|
||||
|
||||
private data class AgendaWindow(val anchor: LocalDate, val pastDays: Int) {
|
||||
val start: LocalDate get() = anchor.minus(pastDays, DateTimeUnit.DAY)
|
||||
}
|
||||
|
||||
private data class AgendaParams(
|
||||
val anchor: LocalDate,
|
||||
val pastDays: Int,
|
||||
val range: AgendaRange,
|
||||
val rangeIsOverride: Boolean,
|
||||
val weekStart: DayOfWeek,
|
||||
val showRangeBar: Boolean,
|
||||
)
|
||||
) {
|
||||
val windowStart: LocalDate get() = anchor.minus(pastDays, DateTimeUnit.DAY)
|
||||
}
|
||||
|
||||
private fun buildState(
|
||||
params: AgendaParams,
|
||||
@@ -181,9 +215,10 @@ class AgendaViewModel @Inject constructor(
|
||||
params.range.dayCount(anchor, params.weekStart) - 1,
|
||||
DateTimeUnit.DAY,
|
||||
)
|
||||
val days = groupAgendaDays(anchor, rangeEnd, instances, zone)
|
||||
val days = groupAgendaDays(params.windowStart, rangeEnd, instances, zone)
|
||||
return AgendaUiState.Success(
|
||||
anchor = anchor,
|
||||
windowStart = params.windowStart,
|
||||
today = today,
|
||||
days = days,
|
||||
range = params.range,
|
||||
@@ -195,9 +230,9 @@ class AgendaViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** How many days each scroll back into the past loads (#367). */
|
||||
internal const val PAST_CHUNK_DAYS = 30
|
||||
|
||||
/** Inclusive instant range from the start of [anchor] through [days] days ahead. */
|
||||
internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange<Instant> {
|
||||
val from = anchor.atStartOfDayIn(zone)
|
||||
val to = anchor.plus(days, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone)
|
||||
return from..to
|
||||
}
|
||||
internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange<Instant> =
|
||||
dayRange(anchor, anchor.plus(days, DateTimeUnit.DAY), zone)
|
||||
|
||||
@@ -6,8 +6,8 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
|
||||
import de.jeanlucmakiola.calendula.ui.common.sampleAgendaEvents
|
||||
import de.jeanlucmakiola.floret.components.ScaledViewPreview
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
@@ -51,6 +51,7 @@ private fun sampleAgendaState(today: LocalDate, zone: TimeZone): AgendaUiState.S
|
||||
val windowEnd = today.plus(PREVIEW_WINDOW_DAYS, DateTimeUnit.DAY)
|
||||
return AgendaUiState.Success(
|
||||
anchor = today,
|
||||
windowStart = today,
|
||||
today = today,
|
||||
days = groupAgendaDays(today, windowEnd, sampleAgendaEvents(today, zone), zone),
|
||||
range = AgendaRange.Custom(PREVIEW_WINDOW_DAYS),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.calendars
|
||||
|
||||
import android.net.Uri
|
||||
import android.text.format.DateUtils
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -54,13 +53,17 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
|
||||
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.locale.relativeTimeAgo
|
||||
import java.time.LocalDate
|
||||
|
||||
// SAF mime filter for the restore picker. Source apps hand `.ics` files out
|
||||
@@ -222,8 +225,8 @@ private fun BackupContent(
|
||||
val onRestoreClick = if (canImport) onRestore else (manageCalendars ?: onRestore)
|
||||
|
||||
if (exportable.isEmpty()) {
|
||||
SectionHeader(stringResource(R.string.calendars_restore_header))
|
||||
HintText(restoreSummary)
|
||||
GroupedSectionHeader(stringResource(R.string.calendars_restore_header))
|
||||
GroupedHint(restoreSummary, style = MaterialTheme.typography.bodySmall)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_restore_action),
|
||||
position = Position.Alone,
|
||||
@@ -233,7 +236,7 @@ private fun BackupContent(
|
||||
return
|
||||
}
|
||||
|
||||
HintText(stringResource(R.string.calendars_backup_hint))
|
||||
GroupedHint(stringResource(R.string.calendars_backup_hint), style = MaterialTheme.typography.bodySmall)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_backup_action),
|
||||
position = Position.Top,
|
||||
@@ -272,7 +275,7 @@ private fun BackupContent(
|
||||
position = Position.Bottom,
|
||||
onClick = onEditInterval,
|
||||
)
|
||||
HintText(backupStatusText(autoBackup.status))
|
||||
GroupedHint(backupStatusText(autoBackup.status), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,12 +312,13 @@ private fun ExportCalendarPicker(
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.calendars_export_title),
|
||||
onDismiss = onDismiss,
|
||||
predictiveBack = true,
|
||||
) {
|
||||
HintText(stringResource(R.string.calendars_export_hint))
|
||||
GroupedHint(stringResource(R.string.calendars_export_hint), style = MaterialTheme.typography.bodySmall)
|
||||
calendars.forEachIndexed { index, calendar ->
|
||||
val isSelected = calendar.id in selected
|
||||
GroupedRow(
|
||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
title = calendar.displayName.orUnnamed(),
|
||||
summary = calendar.description,
|
||||
position = positionOf(index, calendars.size),
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
@@ -376,9 +380,7 @@ private fun backupIntervalLabel(minutes: Long): String {
|
||||
@Composable
|
||||
private fun backupStatusText(status: BackupStatus): String {
|
||||
if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never)
|
||||
val relative = DateUtils.getRelativeTimeSpanString(
|
||||
status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS,
|
||||
).toString()
|
||||
val relative = relativeTimeAgo(status.lastRun)
|
||||
return if (status.lastSuccess) {
|
||||
stringResource(R.string.calendars_auto_backup_status_ok, relative)
|
||||
} else {
|
||||
|
||||
@@ -6,34 +6,27 @@ import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Backup
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
@@ -41,20 +34,14 @@ import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -85,6 +72,7 @@ import de.jeanlucmakiola.calendula.domain.stateLabels
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||
import de.jeanlucmakiola.calendula.ui.common.AccountKey
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarVisibilitySwitch
|
||||
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
@@ -94,11 +82,14 @@ import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
||||
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
||||
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
@@ -224,7 +215,7 @@ private fun CalendarsList(
|
||||
visible = visible,
|
||||
) {
|
||||
// What the per-calendar / per-account switches below actually do.
|
||||
HintText(stringResource(R.string.calendars_visibility_hint))
|
||||
GroupedHint(stringResource(R.string.calendars_visibility_hint), style = MaterialTheme.typography.bodySmall)
|
||||
|
||||
// Local (device-only) calendars — one collapsible group. The header's
|
||||
// "+" adds a calendar; the switch enables/disables them all at once;
|
||||
@@ -245,24 +236,13 @@ private fun CalendarsList(
|
||||
onToggleAll = { enabled -> onSetAccountVisible(local.map { it.id }, enabled) },
|
||||
) {
|
||||
if (local.isEmpty()) {
|
||||
HintText(stringResource(R.string.calendars_local_empty))
|
||||
GroupedHint(stringResource(R.string.calendars_local_empty), style = MaterialTheme.typography.bodySmall)
|
||||
} else {
|
||||
local.forEachIndexed { index, calendar ->
|
||||
val disabled = !calendar.isVisibleInSystem
|
||||
GroupedRow(
|
||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
summary = calendarRowSummary(calendar),
|
||||
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
dimmed = disabled,
|
||||
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||
trailing = {
|
||||
EnableSwitch(
|
||||
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
enabled = !disabled,
|
||||
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
|
||||
)
|
||||
},
|
||||
CalendarManagerRow(
|
||||
calendar = calendar,
|
||||
last = index == local.lastIndex,
|
||||
onSetVisible = onSetVisible,
|
||||
onClick = { onEdit(calendar) },
|
||||
)
|
||||
}
|
||||
@@ -291,8 +271,8 @@ private fun CalendarsList(
|
||||
// Synced calendars — read-only, grouped by account. Each account is a
|
||||
// collapsible group whose header opens the source app (icon) and toggles
|
||||
// all of its calendars at once (switch).
|
||||
SectionHeader(stringResource(R.string.calendars_synced_header))
|
||||
HintText(stringResource(R.string.calendars_synced_hint))
|
||||
GroupedSectionHeader(stringResource(R.string.calendars_synced_header))
|
||||
GroupedHint(stringResource(R.string.calendars_synced_hint), style = MaterialTheme.typography.bodySmall)
|
||||
synced
|
||||
.groupByAccount()
|
||||
.forEach { group ->
|
||||
@@ -333,27 +313,10 @@ private fun CalendarsList(
|
||||
// bottom, dimmed and switchless.
|
||||
val ordered = cals.orderedForManager()
|
||||
ordered.forEachIndexed { index, calendar ->
|
||||
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
|
||||
GroupedRow(
|
||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
summary = calendarRowSummary(calendar),
|
||||
position = if (index == ordered.lastIndex) Position.Bottom else Position.Middle,
|
||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
dimmed = disabled,
|
||||
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||
trailing = if (calendar.hasVisibilitySwitch) {
|
||||
{
|
||||
EnableSwitch(
|
||||
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
enabled = calendar.isVisibleInSystem,
|
||||
onToggle = { enabled ->
|
||||
onSetVisible(calendar.id, enabled)
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
CalendarManagerRow(
|
||||
calendar = calendar,
|
||||
last = index == ordered.lastIndex,
|
||||
onSetVisible = onSetVisible,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -478,7 +441,7 @@ private fun CalendarNameField(
|
||||
onNameChange: (String) -> Unit,
|
||||
onImeAction: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val dark = LocalFloretDarkTheme.current
|
||||
val soften = LocalSoftenColors.current
|
||||
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
Row(
|
||||
@@ -522,7 +485,7 @@ private fun CalendarNameField(
|
||||
*/
|
||||
@Composable
|
||||
private fun CalendarColorGrid(selected: Int, onSelect: (Int) -> Unit) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val dark = LocalFloretDarkTheme.current
|
||||
val soften = LocalSoftenColors.current
|
||||
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
|
||||
Column(
|
||||
@@ -692,17 +655,37 @@ private fun calendarRowSummary(calendar: CalendarSource): String? {
|
||||
* `Calendars.VISIBLE`. Unchecked drops the calendar out of every surface and
|
||||
* stops its reminders. Carries its own content description.
|
||||
*/
|
||||
/**
|
||||
* One calendar in a group: dimmed while switched off or not syncing, with the
|
||||
* visibility switch only where it can change anything.
|
||||
*/
|
||||
@Composable
|
||||
private fun EnableSwitch(
|
||||
calendarName: String,
|
||||
enabled: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
private fun CalendarManagerRow(
|
||||
calendar: CalendarSource,
|
||||
last: Boolean,
|
||||
onSetVisible: (Long, Boolean) -> Unit,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val label = stringResource(R.string.calendars_visibility_a11y, calendarName)
|
||||
Switch(
|
||||
checked = enabled,
|
||||
onCheckedChange = onToggle,
|
||||
modifier = Modifier.semantics { contentDescription = label },
|
||||
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
|
||||
GroupedRow(
|
||||
title = calendar.displayName.orUnnamed(),
|
||||
summary = calendarRowSummary(calendar),
|
||||
position = if (last) Position.Bottom else Position.Middle,
|
||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
dimmed = disabled,
|
||||
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||
trailing = if (calendar.hasVisibilitySwitch) {
|
||||
{
|
||||
CalendarVisibilitySwitch(
|
||||
calendarName = calendar.displayName.orUnnamed(),
|
||||
enabled = calendar.isVisibleInSystem,
|
||||
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -841,33 +824,6 @@ private fun CalendarGroupMenu(
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
internal fun SectionHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
// The cards' own edge, so header and group share a left margin.
|
||||
modifier = Modifier.padding(
|
||||
start = GroupedListInset,
|
||||
end = GroupedListInset,
|
||||
top = 16.dp,
|
||||
bottom = 4.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun HintText(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Pick the app to open for managing a synced calendar's account. The account's
|
||||
* own authenticator package (resolved from [AccountManager], no permission
|
||||
|
||||
@@ -9,16 +9,16 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
@@ -26,7 +26,6 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
@@ -52,9 +51,8 @@ class CalendarsViewModel @Inject constructor(
|
||||
repository.calendars()
|
||||
.catch { emit(emptyList()) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = emptyList(),
|
||||
)
|
||||
|
||||
@@ -68,9 +66,8 @@ class CalendarsViewModel @Inject constructor(
|
||||
.map { backupUiState(it) }
|
||||
.catch { emit(BackupUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = BackupUiState.Loading,
|
||||
)
|
||||
|
||||
@@ -84,9 +81,8 @@ class CalendarsViewModel @Inject constructor(
|
||||
AutoBackupUiState(enabled, interval, folder, status)
|
||||
}
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = AutoBackupUiState(),
|
||||
)
|
||||
|
||||
@@ -103,9 +99,8 @@ class CalendarsViewModel @Inject constructor(
|
||||
if (!enabled) emptySet() else sources.filter { it.isManaged }.map { it.id }.toSet()
|
||||
}
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = emptySet(),
|
||||
)
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ fun List<CalendarSource>.groupByAccount(): List<CalendarAccountGroup> {
|
||||
*/
|
||||
@Composable
|
||||
fun accountGroupTitle(group: CalendarAccountGroup): String {
|
||||
val label = group.label.ifBlank { stringResource(R.string.calendar_unnamed) }
|
||||
val label = group.label.orUnnamed()
|
||||
return if (!group.ambiguous) {
|
||||
label
|
||||
} else {
|
||||
|
||||
@@ -278,12 +278,23 @@ data class BlockPlacement(val xFraction: Float, val widthFraction: Float, val y:
|
||||
* A pinch-zoom rewrites the hour height every pointer frame, and the gutter and
|
||||
* grid lines follow it instantly — so the tween stands down for the gesture
|
||||
* rather than leaving the blocks trailing the ruler they are measured against.
|
||||
* A [landing] block, under a dropped copy, moves without the spring's bounce.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun animatedBlockPlacement(xFraction: Float, widthFraction: Float, y: Dp, height: Dp): BlockPlacement {
|
||||
fun animatedBlockPlacement(
|
||||
xFraction: Float,
|
||||
widthFraction: Float,
|
||||
y: Dp,
|
||||
height: Dp,
|
||||
landing: Boolean = false,
|
||||
): BlockPlacement {
|
||||
val still = rememberReduceMotion() || LocalTimelineZoom.current.isPinching
|
||||
val dpSpec: FiniteAnimationSpec<Dp> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
|
||||
val dpSpec: FiniteAnimationSpec<Dp> = when {
|
||||
still -> snap()
|
||||
landing -> MaterialTheme.motionScheme.fastEffectsSpec()
|
||||
else -> MaterialTheme.motionScheme.fastSpatialSpec()
|
||||
}
|
||||
val shareSpec: FiniteAnimationSpec<Float> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec()
|
||||
val animatedX by animateFloatAsState(xFraction, shareSpec, label = "block-x")
|
||||
val animatedWidth by animateFloatAsState(widthFraction, shareSpec, label = "block-width")
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -16,6 +25,9 @@ import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
@@ -24,6 +36,7 @@ import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
@@ -53,12 +66,39 @@ class ScreenActions(
|
||||
val onCreate: () -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* An event a long press left selected (#141), which the host answers with the
|
||||
* action bar in place of the FAB. [dismiss] clears it on the screen that owns it.
|
||||
* [create] is set for a block drafted on an empty slot (#153), which has no
|
||||
* event yet, so the bar offers to create one.
|
||||
*/
|
||||
@Stable
|
||||
class EventSelection(
|
||||
val event: EventInstance,
|
||||
val dismiss: () -> Unit,
|
||||
val create: (() -> Unit)? = null,
|
||||
/** The event at the times an ongoing move or resize would give it, else null. */
|
||||
val preview: () -> EventInstance? = { null },
|
||||
)
|
||||
|
||||
/** The screen on show's [ScreenChrome] and [ScreenActions], for the host to draw. */
|
||||
@Stable
|
||||
class ChromeHost {
|
||||
var chrome by mutableStateOf<ScreenChrome?>(null)
|
||||
private set
|
||||
|
||||
var selection by mutableStateOf<EventSelection?>(null)
|
||||
internal set
|
||||
|
||||
/** Whether the selected event is being dragged or resized right now. */
|
||||
var selectionBusy by mutableStateOf(false)
|
||||
internal set
|
||||
|
||||
/** Whether the bar would cover the selected event, and so draws compact. */
|
||||
var selectionCompact by mutableStateOf(false)
|
||||
internal set
|
||||
|
||||
|
||||
var actions: ScreenActions? = null
|
||||
private set
|
||||
|
||||
@@ -80,8 +120,54 @@ fun PublishChrome(chrome: ScreenChrome, actions: ScreenActions) {
|
||||
if (slot.current) SideEffect { slot.host.publish(chrome, actions) }
|
||||
}
|
||||
|
||||
/** The top bar every calendar screen shares. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
/** Hands the screen's selected [event] to the host, for as long as it stays selected. */
|
||||
@Composable
|
||||
fun PublishSelection(
|
||||
event: EventInstance?,
|
||||
busy: Boolean,
|
||||
compact: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onCreate: (() -> Unit)? = null,
|
||||
preview: () -> EventInstance? = { null },
|
||||
) {
|
||||
val slot = LocalChromeSlot.current ?: return
|
||||
val currentDismiss by rememberUpdatedState(onDismiss)
|
||||
val currentCreate by rememberUpdatedState(onCreate)
|
||||
val currentPreview by rememberUpdatedState(preview)
|
||||
val drafted = onCreate != null
|
||||
val selection = remember(event, drafted) {
|
||||
event?.let {
|
||||
EventSelection(
|
||||
event = it,
|
||||
dismiss = { currentDismiss() },
|
||||
create = if (drafted) ({ currentCreate?.invoke() }) else null,
|
||||
preview = { currentPreview() },
|
||||
)
|
||||
}
|
||||
}
|
||||
val host = slot.host
|
||||
val current = slot.current
|
||||
DisposableEffect(host, selection, current) {
|
||||
if (current) host.selection = selection
|
||||
onDispose {
|
||||
if (host.selection === selection) host.selection = null
|
||||
// Left mid-drag, nothing else would clear these for the other views.
|
||||
host.selectionBusy = false
|
||||
host.selectionCompact = false
|
||||
}
|
||||
}
|
||||
if (current) {
|
||||
SideEffect {
|
||||
host.selectionBusy = busy && selection != null
|
||||
host.selectionCompact = compact && selection != null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The top bar every calendar screen shares — or, while an event is selected,
|
||||
* the selection's own bar in its place (#141).
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarTopBar(
|
||||
host: ChromeHost,
|
||||
@@ -91,6 +177,43 @@ fun CalendarTopBar(
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
showTodayButton: Boolean,
|
||||
) {
|
||||
val effects = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
||||
AnimatedContent(
|
||||
targetState = host.selection,
|
||||
contentKey = { it != null },
|
||||
transitionSpec = {
|
||||
(fadeIn(effects) + slideInVertically { -it / 4 })
|
||||
.togetherWith(fadeOut(effects) + slideOutVertically { -it / 4 })
|
||||
},
|
||||
label = "top-bar",
|
||||
) { selection ->
|
||||
if (selection != null) {
|
||||
SelectionTopBar(selection)
|
||||
} else {
|
||||
ViewTopBar(
|
||||
host = host,
|
||||
selectedView = selectedView,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
onCycleView = onCycleView,
|
||||
onOpenDrawer = onOpenDrawer,
|
||||
onOpenSearch = onOpenSearch,
|
||||
showTodayButton = showTodayButton,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ViewTopBar(
|
||||
host: ChromeHost,
|
||||
selectedView: CalendarView,
|
||||
quickSwitchViews: List<CalendarView>,
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
showTodayButton: Boolean,
|
||||
) {
|
||||
val chrome = host.chrome
|
||||
TopAppBar(
|
||||
@@ -136,16 +259,26 @@ fun CalendarTopBar(
|
||||
)
|
||||
}
|
||||
|
||||
/** The FAB column every calendar screen shares. */
|
||||
/**
|
||||
* The FAB column every calendar screen shares. While an event is selected the
|
||||
* FAB is [selection]'s toggle instead, and it steps aside mid-gesture.
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean) {
|
||||
fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean, selection: FabSelection? = null) {
|
||||
val chrome = host.chrome ?: return
|
||||
CalendarFabColumn(
|
||||
todayVisible = chrome.awayFromToday && !todayInToolbar,
|
||||
todayText = chrome.todayText,
|
||||
onToday = { host.actions?.onToday?.invoke() },
|
||||
onCreate = { host.actions?.onCreate?.invoke() },
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = !host.selectionBusy,
|
||||
enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeIn(),
|
||||
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()) + fadeOut(),
|
||||
) {
|
||||
CalendarFabColumn(
|
||||
todayVisible = chrome.awayFromToday && !todayInToolbar,
|
||||
todayText = chrome.todayText,
|
||||
onToday = { host.actions?.onToday?.invoke() },
|
||||
onCreate = { host.actions?.onCreate?.invoke() },
|
||||
selection = selection,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
@@ -14,6 +13,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||
|
||||
/**
|
||||
* Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted
|
||||
@@ -23,7 +23,7 @@ import androidx.compose.ui.unit.dp
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val dark = LocalFloretDarkTheme.current
|
||||
val soften = LocalSoftenColors.current
|
||||
Box(
|
||||
modifier = modifier
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.expandHorizontally
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.shrinkHorizontally
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
@@ -15,15 +24,38 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
|
||||
/** The standard FAB's size, shared by the selection bar that unrolls from it (#141). */
|
||||
val FabSize = 56.dp
|
||||
|
||||
/** What the FAB is while an event is selected (#141): the toggle for its action bar. */
|
||||
@Stable
|
||||
class FabSelection(
|
||||
val expanded: Boolean,
|
||||
val onToggle: () -> Unit,
|
||||
val bar: @Composable () -> Unit,
|
||||
)
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* The FAB stack shared by the three calendar views: a persistent "+" to
|
||||
* create an event, with the jump-to-today pill appearing above it whenever
|
||||
* the view isn't anchored on today.
|
||||
* the view isn't anchored on today. While an event is selected the same FAB,
|
||||
* in the same spot, opens and folds the selection's action bar, which unrolls
|
||||
* from it towards the start.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
@@ -32,13 +64,14 @@ fun CalendarFabColumn(
|
||||
todayText: String,
|
||||
onToday: () -> Unit,
|
||||
onCreate: () -> Unit,
|
||||
selection: FabSelection? = null,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = todayVisible,
|
||||
visible = todayVisible && selection == null,
|
||||
enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()),
|
||||
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()),
|
||||
) {
|
||||
@@ -48,11 +81,71 @@ fun CalendarFabColumn(
|
||||
text = { Text(todayText) },
|
||||
)
|
||||
}
|
||||
FloatingActionButton(onClick = onCreate) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = stringResource(R.string.event_edit_new_title),
|
||||
// Held past the selection clearing, so the bar has something to fold away.
|
||||
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),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = selection?.expanded == true,
|
||||
enter = expandHorizontally(
|
||||
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
|
||||
expandFrom = Alignment.End,
|
||||
) + fadeIn(),
|
||||
exit = shrinkHorizontally(
|
||||
animationSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
|
||||
shrinkTowards = Alignment.End,
|
||||
) + fadeOut(),
|
||||
) {
|
||||
heldBar.value?.invoke()
|
||||
}
|
||||
val label = stringResource(
|
||||
when {
|
||||
selection == null -> R.string.event_edit_new_title
|
||||
selection.expanded -> R.string.event_selection_hide
|
||||
else -> R.string.event_selection_actions
|
||||
},
|
||||
)
|
||||
FloatingActionButton(
|
||||
onClick = selection?.onToggle ?: onCreate,
|
||||
modifier = Modifier.semantics { contentDescription = label },
|
||||
) {
|
||||
FabGlyph(selecting = selection != null, expanded = selection?.expanded == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "+", or with an event selected a chevron that turns to open and fold the bar. */
|
||||
@Composable
|
||||
private fun FabGlyph(selecting: Boolean, expanded: Boolean) {
|
||||
val spatial = MaterialTheme.motionScheme.fastSpatialSpec<Float>()
|
||||
val swap by animateFloatAsState(if (selecting) 1f else 0f, spatial, label = "fab-swap")
|
||||
val turn by animateFloatAsState(if (expanded) 0f else 180f, spatial, label = "fab-turn")
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.graphicsLayer {
|
||||
rotationZ = 90f * swap
|
||||
scaleX = 1f - 0.5f * swap
|
||||
scaleY = 1f - 0.5f * swap
|
||||
alpha = (1f - swap).coerceIn(0f, 1f)
|
||||
},
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.graphicsLayer {
|
||||
rotationZ = turn - 90f * (1f - swap)
|
||||
scaleX = 0.5f + 0.5f * swap
|
||||
scaleY = 0.5f + 0.5f * swap
|
||||
alpha = swap.coerceIn(0f, 1f)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
|
||||
/**
|
||||
* The app's single "which calendar" selection list, shared by the event editor
|
||||
@@ -114,17 +113,11 @@ private fun CalendarPickerGroup(
|
||||
leading = leading,
|
||||
)
|
||||
calendars.forEachIndexed { index, calendar ->
|
||||
val isSelected = calendar.id == selectedId
|
||||
GroupedRow(
|
||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
title = calendar.displayName.orUnnamed(),
|
||||
position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle,
|
||||
selected = isSelected,
|
||||
checked = calendar.id == selectedId,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = if (isSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { onSelect(calendar.id) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ package de.jeanlucmakiola.calendula.ui.common
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.style.LineHeightStyle
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
|
||||
/**
|
||||
* The calendar surfaces' line-height treatment. Material wraps an 11sp glyph in
|
||||
@@ -55,3 +57,7 @@ fun rememberTrimmedLineHeight(style: TextStyle): Dp {
|
||||
with(density) { measurer.measure(LINE_SAMPLE, style.trimmedLines()).size.height.toDp() }
|
||||
}
|
||||
}
|
||||
|
||||
/** A calendar or account name, or the "unnamed" label when it is blank. */
|
||||
@Composable
|
||||
fun String.orUnnamed(): String = ifBlank { stringResource(R.string.calendar_unnamed) }
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
|
||||
/** The per-row switch writing the device's own `Calendars.VISIBLE`, as Settings does (#75). */
|
||||
@Composable
|
||||
fun CalendarVisibilitySwitch(
|
||||
calendarName: String,
|
||||
enabled: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
) {
|
||||
val label = stringResource(R.string.calendars_visibility_a11y, calendarName)
|
||||
Switch(
|
||||
checked = enabled,
|
||||
onCheckedChange = onToggle,
|
||||
modifier = Modifier.semantics { contentDescription = label },
|
||||
)
|
||||
}
|
||||
@@ -2,14 +2,16 @@ package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import android.content.ClipData
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
@@ -67,9 +69,12 @@ fun rememberFieldCopier(snackbarHostState: SnackbarHostState): FieldCopier {
|
||||
*
|
||||
* [onTap] carries a field's existing tap action through, announced as
|
||||
* [onTapLabel]; a field without one stays unclickable rather than growing a
|
||||
* ripple that leads nowhere, and gets its long press announced through semantics instead. That branch merges the
|
||||
* node it sits on, so a screen reader lands on the field itself and finds the
|
||||
* action there — a bare container is never focused.
|
||||
* ripple that leads nowhere. The long press is announced through semantics,
|
||||
* merging the node it sits on, so a screen reader lands on the field itself
|
||||
* and finds the action there — a bare container is never focused.
|
||||
*
|
||||
* The press is caught ahead of the field's children, so a long press on a
|
||||
* link inside the text copies too instead of leaving that stretch dead.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.copyOnLongPress(
|
||||
@@ -80,27 +85,45 @@ fun Modifier.copyOnLongPress(
|
||||
onTapLabel: String? = null,
|
||||
): Modifier {
|
||||
val actionLabel = stringResource(R.string.field_copy_action)
|
||||
return if (onTap != null) {
|
||||
combinedClickable(
|
||||
onClick = onTap,
|
||||
onClickLabel = onTapLabel,
|
||||
onLongClickLabel = actionLabel,
|
||||
onLongClick = { copy(label, text) },
|
||||
)
|
||||
} else {
|
||||
val haptics = LocalHapticFeedback.current
|
||||
pointerInput(label, text, copy) {
|
||||
detectTapGestures(
|
||||
onLongPress = {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
copy(label, text)
|
||||
},
|
||||
)
|
||||
}.semantics(mergeDescendants = true) {
|
||||
val haptics = LocalHapticFeedback.current
|
||||
return longPressAheadOfChildren(label, text, copy) {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
copy(label, text)
|
||||
}
|
||||
.then(if (onTap != null) Modifier.clickable(onClickLabel = onTapLabel, onClick = onTap) else Modifier)
|
||||
.semantics(mergeDescendants = true) {
|
||||
onLongClick(actionLabel) {
|
||||
copy(label, text)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run [onLongPress] once a press has held still for the long-press timeout,
|
||||
* watching on the initial pass — before the children see the events — and
|
||||
* then swallow the rest of that press, so neither a link under the finger nor
|
||||
* a tap handler fires on release.
|
||||
*/
|
||||
private fun Modifier.longPressAheadOfChildren(vararg keys: Any?, onLongPress: () -> Unit): Modifier =
|
||||
pointerInput(*keys) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
|
||||
val endedEarly = withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) {
|
||||
while (true) {
|
||||
val change = awaitPointerEvent(PointerEventPass.Initial).changes
|
||||
.firstOrNull { it.id == down.id }
|
||||
val moved = change != null &&
|
||||
(change.position - down.position).getDistance() > viewConfiguration.touchSlop
|
||||
if (change == null || !change.pressed || moved) break
|
||||
}
|
||||
true
|
||||
}
|
||||
if (endedEarly != null) return@awaitEachGesture
|
||||
onLongPress()
|
||||
do {
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
event.changes.forEach { it.consume() }
|
||||
} while (event.changes.any { it.pressed })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Instant range from the start of [first] through the last second of [last] in [zone]. */
|
||||
internal fun dayRange(first: LocalDate, last: LocalDate, zone: TimeZone): ClosedRange<Instant> =
|
||||
first.atStartOfDayIn(zone)..last.atTime(23, 59, 59).toInstant(zone)
|
||||
|
||||
/** Beginning of the week (at [weekStart]) that contains this date. */
|
||||
internal fun LocalDate.startOfWeek(weekStart: DayOfWeek): LocalDate {
|
||||
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
|
||||
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
||||
return minus(offset, DateTimeUnit.DAY)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
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.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
|
||||
import de.jeanlucmakiola.floret.android.hasPermission
|
||||
import de.jeanlucmakiola.floret.components.OptionCard
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* Serialise [detail] to a cache `.ics` and return a shareable content Uri for an
|
||||
* `ACTION_SEND`, or null when the write fails. Exported as a one-off (see
|
||||
* [toShareIcsEvent]). Blocking — call it off the main thread.
|
||||
*/
|
||||
fun IcsExporter.stageEventShare(detail: EventDetail): Uri? = runCatching {
|
||||
val ics = IcsWriter().writeCalendar(
|
||||
events = listOf(detail.toShareIcsEvent()),
|
||||
dtStamp = Clock.System.now(),
|
||||
)
|
||||
stageShareFile(shareFileName(detail.instance.title), ics)
|
||||
}.getOrNull()
|
||||
|
||||
/** Open the system chooser for a staged `.ics`; false when nothing could take it. */
|
||||
fun Context.sendEventShare(uri: Uri, chooserTitle: String): Boolean = runCatching {
|
||||
val send = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/calendar"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
startActivity(Intent.createChooser(send, chooserTitle))
|
||||
}.isSuccess
|
||||
|
||||
/** Delete an event, or as much of its series as [scope] reaches from the occurrence at [beginMillis]. */
|
||||
suspend fun CalendarRepository.deleteEvent(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
scope: RecurringWriteScope,
|
||||
) = when (scope) {
|
||||
RecurringWriteScope.AllEvents -> deleteEvent(eventId)
|
||||
RecurringWriteScope.ThisEvent -> deleteOccurrence(eventId, beginMillis)
|
||||
RecurringWriteScope.ThisAndFollowing -> deleteEventFromOccurrence(eventId, beginMillis)
|
||||
}
|
||||
|
||||
/** 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)
|
||||
|
||||
/** [deleteEvent], with the provider's failures mapped to a [DeleteOutcome]. */
|
||||
suspend fun CalendarRepository.tryDeleteEvent(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
scope: RecurringWriteScope,
|
||||
): DeleteOutcome = try {
|
||||
deleteEvent(eventId, beginMillis, scope)
|
||||
DeleteOutcome.Deleted
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: SecurityException) {
|
||||
DeleteOutcome.NeedsPermission
|
||||
} catch (e: NoSuchEventException) {
|
||||
DeleteOutcome.Gone
|
||||
} catch (e: Exception) {
|
||||
Log.w("EventActions", "Delete failed (scope=$scope, eventId=$eventId)", e)
|
||||
DeleteOutcome.Failed
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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
|
||||
var pending by remember { mutableStateOf<Pair<() -> Unit, () -> Unit>?>(null) }
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
pending?.let { (action, fallback) -> if (granted) action() else fallback() }
|
||||
pending = null
|
||||
}
|
||||
return { action, fallback ->
|
||||
if (context.canWriteCalendar()) {
|
||||
action()
|
||||
} else {
|
||||
pending = action to fallback
|
||||
launcher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete confirmation. Recurring events choose between cancelling just the
|
||||
* tapped occurrence (default), truncating the series from it onwards, and
|
||||
* removing the whole series.
|
||||
*/
|
||||
@Composable
|
||||
fun DeleteEventDialog(
|
||||
isRecurring: Boolean,
|
||||
onConfirm: (RecurringWriteScope) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var scope by rememberSaveable { mutableStateOf(RecurringWriteScope.ThisEvent) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
stringResource(
|
||||
if (isRecurring) R.string.event_delete_recurring_title
|
||||
else R.string.event_delete_title,
|
||||
),
|
||||
)
|
||||
},
|
||||
text = {
|
||||
if (isRecurring) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_delete_option_occurrence),
|
||||
onClick = { scope = RecurringWriteScope.ThisEvent },
|
||||
selected = scope == RecurringWriteScope.ThisEvent,
|
||||
)
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_delete_option_following),
|
||||
onClick = { scope = RecurringWriteScope.ThisAndFollowing },
|
||||
selected = scope == RecurringWriteScope.ThisAndFollowing,
|
||||
)
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_delete_option_series),
|
||||
onClick = { scope = RecurringWriteScope.AllEvents },
|
||||
selected = scope == RecurringWriteScope.AllEvents,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(stringResource(R.string.event_delete_body))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onConfirm(if (isRecurring) scope else RecurringWriteScope.AllEvents) },
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.event_detail_delete),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.dialog_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */
|
||||
private fun shareFileName(title: String): String {
|
||||
val base = title.trim()
|
||||
.replace(Regex("""[^\p{L}\p{Nd} _-]"""), "")
|
||||
.replace(' ', '_')
|
||||
.take(40)
|
||||
.ifBlank { "event" }
|
||||
return "$base.ics"
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.atOccurrence
|
||||
import de.jeanlucmakiola.calendula.domain.toDuplicateForm
|
||||
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.first
|
||||
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
|
||||
|
||||
/** A delete waiting on its confirmation dialog. */
|
||||
data class DeletePrompt(val event: EventInstance, val isRecurring: Boolean)
|
||||
|
||||
/** How an action on a selected event ended, when it has something to say. */
|
||||
enum class EventActionFailure { LoadFailed, ShareFailed, DeleteFailed, WriteDenied, Gone }
|
||||
|
||||
/** Duplicate, share and delete for a selected event (#141), loading it on demand. */
|
||||
@HiltViewModel
|
||||
class EventActionsViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
private val icsExporter: IcsExporter,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
/** Every calendar by id, for what a selected event's bar may offer. */
|
||||
val calendars: StateFlow<Map<Long, CalendarSource>> = repository.calendars()
|
||||
.map { list -> list.associateBy { it.id } }
|
||||
.catch { emit(emptyMap()) }
|
||||
.flowOn(io)
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
initialValue = emptyMap(),
|
||||
)
|
||||
|
||||
private val _deletePrompt = MutableStateFlow<DeletePrompt?>(null)
|
||||
val deletePrompt: StateFlow<DeletePrompt?> = _deletePrompt.asStateFlow()
|
||||
|
||||
private val _failure = MutableStateFlow<EventActionFailure?>(null)
|
||||
val failure: StateFlow<EventActionFailure?> = _failure.asStateFlow()
|
||||
|
||||
/** A create form copying [event], or null when it can't be read. */
|
||||
suspend fun duplicateForm(event: EventInstance): EventForm? {
|
||||
val detail = load(event) ?: return null
|
||||
val canModify = calendars.value[event.calendarId]?.canModifyContents == true
|
||||
return detail.toDuplicateForm(canModify, TimeZone.currentSystemDefault())
|
||||
}
|
||||
|
||||
/** A staged `.ics` for [event], or null — reported — when it can't be built. */
|
||||
suspend fun shareUri(event: EventInstance): Uri? {
|
||||
val uri = load(event)?.let { withContext(io) { icsExporter.stageEventShare(it) } }
|
||||
if (uri == null && _failure.value == null) _failure.value = EventActionFailure.ShareFailed
|
||||
return uri
|
||||
}
|
||||
|
||||
/** Ask to delete [event]; the dialog it needs depends on whether it recurs. */
|
||||
fun requestDelete(event: EventInstance) {
|
||||
viewModelScope.launch {
|
||||
val detail = load(event) ?: return@launch
|
||||
_deletePrompt.value = DeletePrompt(event, isRecurring = !detail.rrule.isNullOrBlank())
|
||||
}
|
||||
}
|
||||
|
||||
fun confirmDelete(scope: RecurringWriteScope) {
|
||||
val prompt = _deletePrompt.value ?: return
|
||||
_deletePrompt.value = null
|
||||
viewModelScope.launch {
|
||||
val event = prompt.event
|
||||
_failure.value = when (repository.tryDeleteEvent(event.eventId, event.start.toEpochMilliseconds(), scope)) {
|
||||
DeleteOutcome.Deleted -> null
|
||||
DeleteOutcome.Gone -> EventActionFailure.Gone
|
||||
DeleteOutcome.NeedsPermission -> EventActionFailure.WriteDenied
|
||||
DeleteOutcome.Failed -> EventActionFailure.DeleteFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelDelete() {
|
||||
_deletePrompt.value = null
|
||||
}
|
||||
|
||||
/** The chooser itself couldn't open — nothing on the device takes an `.ics`. */
|
||||
fun reportShareFailed() {
|
||||
_failure.value = EventActionFailure.ShareFailed
|
||||
}
|
||||
|
||||
fun consumeFailure() {
|
||||
_failure.value = null
|
||||
}
|
||||
|
||||
/** Whether [calendarId] takes writes, asking the provider while [calendars] hasn't loaded yet. */
|
||||
suspend fun canModify(calendarId: Long): Boolean {
|
||||
val calendar = calendars.value[calendarId] ?: try {
|
||||
repository.calendars().first().firstOrNull { it.id == calendarId }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
return calendar?.canModifyContents == true
|
||||
}
|
||||
|
||||
private suspend fun load(event: EventInstance): EventDetail? = try {
|
||||
// The row holds the series start; the actions work on this occurrence.
|
||||
repository.eventDetail(event.eventId)
|
||||
.atOccurrence(event.start.toEpochMilliseconds(), event.end.toEpochMilliseconds())
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: NoSuchEventException) {
|
||||
_failure.value = EventActionFailure.Gone
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
_failure.value = EventActionFailure.LoadFailed
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -24,38 +24,57 @@ import androidx.compose.ui.unit.dp
|
||||
/** How far the finger may wander during the hold — well under touch slop. */
|
||||
private val PICKUP_TOLERANCE = 6.dp
|
||||
|
||||
/** What a long press on a block turned into. */
|
||||
enum class PickUp {
|
||||
/** Nothing to pick up; the gesture is left alone. */
|
||||
None,
|
||||
|
||||
/** Lifted and following the finger. */
|
||||
Drag,
|
||||
|
||||
/** Held but not movable — the gesture is swallowed until the finger lifts. */
|
||||
Hold,
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick an event block up with a long press and drag it, without
|
||||
* `detectDragGesturesAfterLongPress`: the stock detector cancels as soon as an
|
||||
* ancestor consumes or the finger leaves the block, which a
|
||||
* `MIN_EVENT_FRACTION`-tall block loses immediately. Movement before the timeout
|
||||
* is deliberately not consumed, so a scroll starting on top of a block survives.
|
||||
*
|
||||
* [onRelease] is the long press that never moved: the finger lifted within touch
|
||||
* slop of where it was picked up, or a [PickUp.Hold] ended. Without it, such a
|
||||
* release is a drop like any other. [immediate] also lifts the block as soon as
|
||||
* the finger moves past touch slop, with no hold — for a block already selected.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberEventDragSource(
|
||||
enabled: Boolean,
|
||||
key: Any?,
|
||||
onPickUp: (pointerInRoot: Offset, blockInRoot: Offset, size: IntSize) -> Unit,
|
||||
onPickUp: (pointerInRoot: Offset, blockInRoot: Offset, size: IntSize) -> PickUp,
|
||||
onMove: (pointerInRoot: Offset) -> Unit,
|
||||
onDrop: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
): Modifier = rememberDragSurface(
|
||||
onRelease: (() -> Unit)? = null,
|
||||
immediate: Boolean = false,
|
||||
): Modifier = rememberPickUpSurface(
|
||||
enabled = enabled,
|
||||
key = key,
|
||||
onPickUp = { _, pointerInRoot, nodeInRoot, size ->
|
||||
onPickUp(pointerInRoot, nodeInRoot, size)
|
||||
true
|
||||
},
|
||||
immediate = immediate,
|
||||
onPickUp = { _, pointerInRoot, nodeInRoot, size -> onPickUp(pointerInRoot, nodeInRoot, size) },
|
||||
onMove = onMove,
|
||||
onDrop = onDrop,
|
||||
onCancel = onCancel,
|
||||
onRelease = onRelease,
|
||||
)
|
||||
|
||||
/**
|
||||
* The same pickup, for a surface that carries many draggable pieces rather than
|
||||
* being one itself — the month grid, whose chips are covered by a full-bleed tap
|
||||
* layer. [onPickUp] receives the press position local to this node and answers
|
||||
* whether anything is there; false abandons the gesture.
|
||||
* whether anything is there; false abandons the gesture. [skipClaimedDown] leaves
|
||||
* a press a descendant already consumed alone.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberDragSurface(
|
||||
@@ -65,6 +84,33 @@ fun rememberDragSurface(
|
||||
onMove: (pointerInRoot: Offset) -> Unit,
|
||||
onDrop: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onRelease: (() -> Unit)? = null,
|
||||
skipClaimedDown: Boolean = false,
|
||||
): Modifier = rememberPickUpSurface(
|
||||
enabled = enabled,
|
||||
key = key,
|
||||
immediate = false,
|
||||
onPickUp = { local, pointer, node, size ->
|
||||
if (onPickUp(local, pointer, node, size)) PickUp.Drag else PickUp.None
|
||||
},
|
||||
onMove = onMove,
|
||||
onDrop = onDrop,
|
||||
onCancel = onCancel,
|
||||
onRelease = onRelease,
|
||||
skipClaimedDown = skipClaimedDown,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun rememberPickUpSurface(
|
||||
enabled: Boolean,
|
||||
key: Any?,
|
||||
immediate: Boolean,
|
||||
onPickUp: (local: Offset, pointerInRoot: Offset, nodeInRoot: Offset, size: IntSize) -> PickUp,
|
||||
onMove: (pointerInRoot: Offset) -> Unit,
|
||||
onDrop: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onRelease: (() -> Unit)?,
|
||||
skipClaimedDown: Boolean = false,
|
||||
): Modifier {
|
||||
if (!enabled) return Modifier
|
||||
val holdMillis = LocalViewConfiguration.current.longPressTimeoutMillis
|
||||
@@ -74,27 +120,44 @@ fun rememberDragSurface(
|
||||
val currentMove by rememberUpdatedState(onMove)
|
||||
val currentDrop by rememberUpdatedState(onDrop)
|
||||
val currentCancel by rememberUpdatedState(onCancel)
|
||||
val currentRelease by rememberUpdatedState(onRelease)
|
||||
// Not a key: the pickup changes it, and a restart would cancel the drag.
|
||||
val currentImmediate by rememberUpdatedState(immediate)
|
||||
|
||||
return Modifier
|
||||
.onGloballyPositioned { coordinates[0] = it }
|
||||
.pointerInput(key) {
|
||||
val slop = viewConfiguration.touchSlop
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
// A child that took the press, such as a resize handle, keeps it.
|
||||
if (skipClaimedDown && down.isConsumed) return@awaitEachGesture
|
||||
val origin = down.position
|
||||
val heldStill = withTimeoutOrNull(holdMillis) {
|
||||
while (true) {
|
||||
val immediate = currentImmediate
|
||||
// Null: held still. True: an immediate pickup past slop. False: abandoned.
|
||||
val outcome = withTimeoutOrNull(holdMillis) {
|
||||
var result: Boolean? = null
|
||||
while (result == null) {
|
||||
val main = awaitPointerEvent()
|
||||
val change = main.changes.firstOrNull { it.id == down.id } ?: break
|
||||
if (!change.pressed) break
|
||||
if (main.changes.count { it.pressed } > 1) break
|
||||
if ((change.position - origin).getDistance() > tolerance) break
|
||||
// An ancestor's claim only becomes visible once the whole
|
||||
// main pass has run, so look again on the final pass.
|
||||
val final = awaitPointerEvent(PointerEventPass.Final)
|
||||
if (final.changes.any { it.isConsumed }) break
|
||||
val change = main.changes.firstOrNull { it.id == down.id }
|
||||
val distance = change?.let { (it.position - origin).getDistance() } ?: 0f
|
||||
result = when {
|
||||
change == null || !change.pressed -> false
|
||||
main.changes.count { it.pressed } > 1 -> false
|
||||
immediate && distance > slop && !change.isConsumed -> {
|
||||
change.consume()
|
||||
true
|
||||
}
|
||||
!immediate && distance > tolerance -> false
|
||||
// An ancestor's claim only becomes visible once the
|
||||
// whole main pass has run, so look again on the final pass.
|
||||
awaitPointerEvent(PointerEventPass.Final).changes.any { it.isConsumed } -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
} == null
|
||||
if (!heldStill) return@awaitEachGesture
|
||||
result
|
||||
}
|
||||
if (outcome == false) return@awaitEachGesture
|
||||
|
||||
val layout = coordinates[0]?.takeIf { it.isAttached } ?: return@awaitEachGesture
|
||||
val took = currentPickUp(
|
||||
@@ -103,8 +166,11 @@ fun rememberDragSurface(
|
||||
layout.positionInRoot(),
|
||||
layout.size,
|
||||
)
|
||||
if (!took) return@awaitEachGesture
|
||||
if (took == PickUp.None) return@awaitEachGesture
|
||||
// Moved already, for an immediate pickup: it can't be a release.
|
||||
var moved = outcome == true
|
||||
var dropped = false
|
||||
var released = false
|
||||
try {
|
||||
// Driven on the initial pass, which runs parent → child: an
|
||||
// ancestor that outranks us (the pinch) has already consumed
|
||||
@@ -116,28 +182,51 @@ fun rememberDragSurface(
|
||||
if (change.isConsumed) break
|
||||
if (!change.pressed) {
|
||||
change.consume()
|
||||
dropped = true
|
||||
if (took == PickUp.Hold || (!moved && currentRelease != null)) {
|
||||
released = true
|
||||
} else {
|
||||
dropped = true
|
||||
}
|
||||
break
|
||||
}
|
||||
if (event.changes.count { it.pressed } > 1) break
|
||||
change.consume()
|
||||
if (took == PickUp.Hold) continue
|
||||
if ((change.position - origin).getDistance() > slop) moved = true
|
||||
coordinates[0]?.takeIf { it.isAttached }
|
||||
?.let { currentMove(it.localToRoot(change.position)) }
|
||||
}
|
||||
} finally {
|
||||
// Also reached when the pointer node is disposed mid-drag —
|
||||
// a cancel, not a drop, and must never write.
|
||||
if (dropped) currentDrop() else currentCancel()
|
||||
when {
|
||||
released -> {
|
||||
if (took == PickUp.Drag) currentCancel()
|
||||
currentRelease?.invoke()
|
||||
}
|
||||
dropped -> currentDrop()
|
||||
took == PickUp.Drag -> currentCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A lift on pickup, then a tick every time the drop target snaps to a new slot. */
|
||||
/**
|
||||
* A lift on pickup, a tick every time the drop target snaps to a new slot, and a
|
||||
* confirm each time [drops] counts a real drop.
|
||||
*/
|
||||
@Composable
|
||||
fun DragSnapHaptics(slot: Any?) {
|
||||
fun DragSnapHaptics(slot: Any?, drops: Int) {
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val previous = remember { arrayOfNulls<Any>(1) }
|
||||
val dropsSeen = remember { intArrayOf(drops) }
|
||||
LaunchedEffect(drops) {
|
||||
if (drops != dropsSeen[0]) {
|
||||
dropsSeen[0] = drops
|
||||
haptics.performHapticFeedback(HapticFeedbackType.Confirm)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(slot) {
|
||||
val had = previous[0]
|
||||
previous[0] = slot
|
||||
|
||||
@@ -48,7 +48,12 @@ private val FAB_BAND = 88.dp
|
||||
* a pill on the FAB's band, leaving the calendar visible behind it.
|
||||
*/
|
||||
@Composable
|
||||
fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier) {
|
||||
fun EventMoveHost(
|
||||
viewModel: RescheduleViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
actionFailure: String? = null,
|
||||
onActionFailureShown: () -> Unit = {},
|
||||
) {
|
||||
val prompt by viewModel.scopePrompt.collectAsStateWithLifecycle()
|
||||
val outcome by viewModel.outcome.collectAsStateWithLifecycle()
|
||||
val undoTick by viewModel.undoStarted.collectAsStateWithLifecycle()
|
||||
@@ -58,7 +63,9 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
|
||||
|
||||
prompt?.let { pending ->
|
||||
RecurringScopeDialog(
|
||||
title = stringResource(R.string.event_move_recurring_title),
|
||||
title = stringResource(
|
||||
if (pending.resize) R.string.event_resize_recurring_title else R.string.event_move_recurring_title,
|
||||
),
|
||||
onSelect = viewModel::moveWithScope,
|
||||
onDismiss = viewModel::cancelScope,
|
||||
allowSeries = !pending.occurrenceOnly,
|
||||
@@ -69,9 +76,17 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
|
||||
|
||||
val moved = outcome as? MoveOutcome.Moved
|
||||
val movedLabel = moved?.let { formatMovedTo(it.startMillis, it.isAllDay, use24Hour, locale) }
|
||||
val resizedLabel = moved?.endMillis?.let { end ->
|
||||
"$movedLabel–${formatTime(end, use24Hour, locale)}"
|
||||
}
|
||||
// A failed selection action (#141) shares the chip rather than stacking on it.
|
||||
val message = when (outcome) {
|
||||
null -> null
|
||||
is MoveOutcome.Moved -> stringResource(R.string.event_move_done, movedLabel.orEmpty())
|
||||
null -> actionFailure
|
||||
is MoveOutcome.Moved -> if (resizedLabel != null) {
|
||||
stringResource(R.string.event_resize_done, resizedLabel)
|
||||
} else {
|
||||
stringResource(R.string.event_move_done, movedLabel.orEmpty())
|
||||
}
|
||||
MoveOutcome.Undone -> stringResource(R.string.event_move_undone)
|
||||
MoveOutcome.WriteDenied -> stringResource(R.string.event_move_write_denied)
|
||||
MoveOutcome.Gone -> stringResource(R.string.event_move_gone)
|
||||
@@ -96,6 +111,12 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
|
||||
delay(if (outcome == MoveOutcome.Undone) UNDONE_CHIP_MILLIS else CHIP_MILLIS)
|
||||
viewModel.consumeOutcome()
|
||||
}
|
||||
// A failure waits for the chip to be free, then has its own turn.
|
||||
LaunchedEffect(actionFailure, outcome == null) {
|
||||
if (actionFailure == null || outcome != null) return@LaunchedEffect
|
||||
delay(CHIP_MILLIS)
|
||||
onActionFailureShown()
|
||||
}
|
||||
|
||||
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
||||
val chipMaxWidth = maxWidth - FAB_BAND
|
||||
@@ -113,7 +134,7 @@ fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier)
|
||||
// write anyway, so offering it would be a button that does nothing.
|
||||
val undo = content.undo?.takeIf { !writeInFlight }
|
||||
SnackChip(
|
||||
visible = outcome != null,
|
||||
visible = outcome != null || actionFailure != null,
|
||||
message = content.message,
|
||||
maxWidth = chipMaxWidth,
|
||||
actionLabel = stringResource(R.string.event_move_undo).takeIf { undo != null },
|
||||
@@ -142,3 +163,7 @@ private fun formatMovedTo(
|
||||
return localizedDateFormatter(locale, skeleton)
|
||||
.format(Instant.ofEpochMilli(startMillis).atZone(zone))
|
||||
}
|
||||
|
||||
/** "10:30" — the end of a resized event, read in the device zone like its start. */
|
||||
private fun formatTime(millis: Long, use24Hour: Boolean, locale: Locale): String =
|
||||
timeOfDayFormatter(use24Hour, locale).format(Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()))
|
||||
|
||||
@@ -131,6 +131,13 @@ fun eventDragAllowed(event: EventInstance): Boolean {
|
||||
return move.dragEnabled && move.allows(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a long press may select events at all (#141) — the gesture setting,
|
||||
* whatever the event's calendar allows. A read-only event is selected but stays put.
|
||||
*/
|
||||
@Composable
|
||||
fun eventSelectAllowed(): Boolean = LocalEventMove.current?.dragEnabled == true
|
||||
|
||||
/**
|
||||
* A TalkBack action that opens [event] in the edit form, so rescheduling isn't
|
||||
* pointer-only. Null when this event can't be moved.
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
|
||||
/** The selected event's action bar (#141); Edit and Delete only where its calendar allows. */
|
||||
@Composable
|
||||
fun EventSelectionBar(
|
||||
canEdit: Boolean,
|
||||
canDelete: Boolean,
|
||||
onEdit: () -> Unit,
|
||||
onDuplicate: () -> Unit,
|
||||
onShare: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// The FAB's height and colours, so the two read as one control.
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
shadowElevation = 6.dp,
|
||||
modifier = modifier.height(FabSize),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
) {
|
||||
if (canEdit) BarAction(Icons.Default.Edit, R.string.event_detail_edit, onEdit)
|
||||
BarAction(Icons.Default.ContentCopy, R.string.event_detail_duplicate, onDuplicate)
|
||||
BarAction(Icons.Default.Share, R.string.event_detail_share, onShare)
|
||||
if (canDelete) BarAction(Icons.Default.Delete, R.string.event_detail_delete, onDelete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A drafted block's bar (#153): create the event, or let the draft go. */
|
||||
@Composable
|
||||
fun DraftSelectionBar(onCreate: () -> Unit, onDiscard: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
shadowElevation = 6.dp,
|
||||
modifier = modifier.height(FabSize),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
) {
|
||||
BarAction(Icons.Default.Close, R.string.event_draft_discard, onDiscard)
|
||||
BarAction(Icons.Default.Check, R.string.event_draft_create, onCreate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BarAction(icon: ImageVector, label: Int, onClick: () -> Unit) {
|
||||
IconButton(onClick = onClick) {
|
||||
Icon(imageVector = icon, contentDescription = stringResource(label))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.floret.components.SnackChipMargin
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** The bottom band the selection bar and FAB cover, margin included. */
|
||||
val SelectionBarClearance = FabSize + SnackChipMargin
|
||||
|
||||
/** The FAB's role while an event is selected (#141): its action bar's toggle. Null otherwise. */
|
||||
@Composable
|
||||
fun rememberFabSelection(
|
||||
host: ChromeHost,
|
||||
viewModel: EventActionsViewModel,
|
||||
onEdit: (EventInstance) -> Unit,
|
||||
onDuplicate: (EventForm) -> Unit,
|
||||
chipShown: Boolean,
|
||||
onDismissChip: () -> Unit,
|
||||
): FabSelection? {
|
||||
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.
|
||||
var chosen by remember(host.selection?.event) { mutableStateOf<Boolean?>(null) }
|
||||
// Folded while a chip is up, which then sits beside the FAB as usual.
|
||||
val expanded = !chipShown && (chosen ?: !host.selectionCompact)
|
||||
|
||||
val currentEdit by rememberUpdatedState(onEdit)
|
||||
val currentDuplicate by rememberUpdatedState(onDuplicate)
|
||||
val currentDismissChip by rememberUpdatedState(onDismissChip)
|
||||
val currentWrite by rememberUpdatedState(requireWrite)
|
||||
|
||||
val selection = host.selection ?: return null
|
||||
selection.create?.let { create ->
|
||||
return remember(selection, expanded, chipShown) {
|
||||
FabSelection(
|
||||
expanded = expanded,
|
||||
onToggle = {
|
||||
if (chipShown) currentDismissChip()
|
||||
chosen = !expanded
|
||||
},
|
||||
bar = {
|
||||
DraftSelectionBar(
|
||||
onCreate = { currentWrite { create() } },
|
||||
onDiscard = selection.dismiss,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
val event = selection.event
|
||||
val calendar = calendars[event.calendarId]
|
||||
val canEdit = calendar?.canModifyContents == true
|
||||
val canDelete = canEdit && calendar?.isManaged == false
|
||||
return remember(selection, expanded, chipShown, canEdit, canDelete) {
|
||||
FabSelection(
|
||||
expanded = expanded,
|
||||
onToggle = {
|
||||
if (chipShown) currentDismissChip()
|
||||
chosen = !expanded
|
||||
},
|
||||
bar = {
|
||||
EventSelectionBar(
|
||||
canEdit = canEdit,
|
||||
canDelete = canDelete,
|
||||
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 = { currentWrite { viewModel.requestDelete(event) } },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** What a failed action on a selected event says, for the move host's chip to show (#141). */
|
||||
@Composable
|
||||
fun eventActionFailureMessage(failure: EventActionFailure?): String? = when (failure) {
|
||||
null -> null
|
||||
EventActionFailure.LoadFailed -> stringResource(R.string.event_action_load_failed)
|
||||
EventActionFailure.ShareFailed -> stringResource(R.string.event_share_failed)
|
||||
EventActionFailure.DeleteFailed -> stringResource(R.string.event_delete_failed)
|
||||
EventActionFailure.WriteDenied -> stringResource(R.string.event_delete_write_denied)
|
||||
EventActionFailure.Gone -> stringResource(R.string.event_move_gone)
|
||||
}
|
||||
|
||||
/** The delete confirmation a selected event's Delete brings up (#141). */
|
||||
@Composable
|
||||
fun EventSelectionHost(host: ChromeHost, viewModel: EventActionsViewModel) {
|
||||
val deletePrompt by viewModel.deletePrompt.collectAsStateWithLifecycle()
|
||||
deletePrompt?.let { prompt ->
|
||||
DeleteEventDialog(
|
||||
isRecurring = prompt.isRecurring,
|
||||
onConfirm = { writeScope ->
|
||||
host.selection?.takeIf { it.event == prompt.event }?.dismiss()
|
||||
viewModel.confirmDelete(writeScope)
|
||||
},
|
||||
onDismiss = viewModel::cancelDelete,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -19,12 +19,15 @@ import androidx.compose.runtime.Composable
|
||||
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
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
|
||||
/** Width of the hour gutter down the start edge of the day and week timelines. */
|
||||
@@ -67,7 +70,7 @@ fun HourGutter(
|
||||
val locale = currentLocale()
|
||||
// Derived: the drag is rewritten every frame, its snapped start once a slot.
|
||||
val dragStartMin by remember(dragController) {
|
||||
derivedStateOf { dragController.drag?.startMin }
|
||||
derivedStateOf { dragController.drag?.markerMin }
|
||||
}
|
||||
val hourAlpha by animateFloatAsState(
|
||||
targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f,
|
||||
@@ -107,12 +110,25 @@ fun HourGutter(
|
||||
}
|
||||
}
|
||||
}
|
||||
dragStartMin?.let { startMin ->
|
||||
// Held while it fades, so it goes out where it was rather than blinking off.
|
||||
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.value?.takeIf { badgeIn > 0f }?.let { startMin ->
|
||||
val top = (hourHeight * (startMin / 60f) - BADGE_HEIGHT / 2).coerceAtLeast(0.dp)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.offset(y = top)
|
||||
.graphicsLayer {
|
||||
alpha = badgeIn
|
||||
scaleX = 0.8f + 0.2f * badgeIn
|
||||
scaleY = 0.8f + 0.2f * badgeIn
|
||||
}
|
||||
.height(BADGE_HEIGHT)
|
||||
.background(MaterialTheme.colorScheme.primary, CircleShape)
|
||||
.padding(horizontal = 4.dp)
|
||||
@@ -121,7 +137,8 @@ fun HourGutter(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = formatGutterTime(startMin, use24Hour, locale),
|
||||
// A resized end can sit on midnight: 00:00, not a clamped 23:59.
|
||||
text = formatGutterTime(startMin % MINUTES_PER_DAY, use24Hour, locale),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
maxLines = 1,
|
||||
|
||||
@@ -195,15 +195,20 @@ class OutOfViewEdges internal constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a press at [y] (content px) lands on an edge bar (#153). */
|
||||
fun covers(y: Float): Boolean = edgeAt(y) != null
|
||||
|
||||
private fun edgeAt(y: Float) = tappedEdge(
|
||||
events = events,
|
||||
y = y,
|
||||
viewportTop = scrollState.value.toFloat(),
|
||||
viewportPx = scrollState.viewportSize,
|
||||
reachPx = reachPx,
|
||||
)
|
||||
|
||||
/** Reveals the hidden event if a tap at [y] (content px) lands on a bar. */
|
||||
fun tap(y: Float): Boolean {
|
||||
val edge = tappedEdge(
|
||||
events = events,
|
||||
y = y,
|
||||
viewportTop = scrollState.value.toFloat(),
|
||||
viewportPx = scrollState.viewportSize,
|
||||
reachPx = reachPx,
|
||||
) ?: return false
|
||||
val edge = edgeAt(y) ?: return false
|
||||
reveal(edge)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.plus
|
||||
@@ -45,7 +45,7 @@ class PageStateCache<K, S>(
|
||||
return entries.getOrPut(key) {
|
||||
val job = SupervisorJob(scope.coroutineContext[Job])
|
||||
Entry(
|
||||
state = load(key).stateIn(scope + job, SharingStarted.WhileSubscribed(5_000L), initial),
|
||||
state = load(key).stateWhileSubscribed(scope + job, initial),
|
||||
job = job,
|
||||
)
|
||||
}.state
|
||||
|
||||
@@ -5,6 +5,11 @@ import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
@@ -16,6 +21,17 @@ import kotlin.time.Instant
|
||||
*/
|
||||
val LocalDimCutoff = compositionLocalOf<Instant?> { null }
|
||||
|
||||
/**
|
||||
* Whether [this] counts as past where it's drawn on [day]: once it has ended,
|
||||
* or on any day before the one [now] falls in, so a multi-day event still
|
||||
* running doesn't stay undimmed on the days already behind it.
|
||||
*/
|
||||
fun EventInstance.isPastOn(
|
||||
day: LocalDate,
|
||||
now: Instant,
|
||||
zone: TimeZone = TimeZone.currentSystemDefault(),
|
||||
): Boolean = hasEnded(now) || day < now.toLocalDateTime(zone).date
|
||||
|
||||
/** Opacity applied to a completed/past event chip when it is dimmed. */
|
||||
const val EventDimAlpha = 0.4f
|
||||
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -22,17 +16,16 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.floret.components.CustomAmountEditor
|
||||
import de.jeanlucmakiola.floret.components.CustomExpandRow
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||
import de.jeanlucmakiola.floret.reminders.minutesOrEmpty
|
||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.agendaRangeWindowSummary
|
||||
@@ -90,7 +83,7 @@ fun ReminderDefaultPicker(
|
||||
}
|
||||
val inherits = current is ReminderOverride.Inherit
|
||||
val isNone = current is ReminderOverride.None
|
||||
val selectedMinutes = (current as? ReminderOverride.Minutes)?.minutes.orEmpty()
|
||||
val selectedMinutes = current.minutesOrEmpty()
|
||||
// Custom (non-preset) lead times seen this session, so unchecking one keeps
|
||||
// its row (unchecked) until the picker closes instead of vanishing mid-tap,
|
||||
// which would strand a hand-entered value with no way to re-check it.
|
||||
@@ -121,24 +114,14 @@ fun ReminderDefaultPicker(
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.reminder_use_default),
|
||||
position = Position.Top,
|
||||
selected = inherits,
|
||||
trailing = if (inherits) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
checked = inherits,
|
||||
onClick = { apply(ReminderOverride.Inherit) },
|
||||
)
|
||||
}
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.reminder_none),
|
||||
position = if (allowInherit) Position.Bottom else Position.Alone,
|
||||
selected = isNone,
|
||||
trailing = if (isNone) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
checked = isNone,
|
||||
onClick = { apply(ReminderOverride.None) },
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
@@ -154,17 +137,11 @@ fun ReminderDefaultPicker(
|
||||
onClick = { toggle(minute) },
|
||||
)
|
||||
}
|
||||
// When expanded, the Custom row connects downward into the editor card
|
||||
// so the two read as one grouped container (the per-calendar pattern).
|
||||
GroupedRow(
|
||||
CustomExpandRow(
|
||||
title = stringResource(R.string.event_edit_reminder_custom),
|
||||
position = if (customExpanded) Position.Top else positionOf(rows.size, rowCount),
|
||||
onClick = { customExpanded = !customExpanded },
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = customExpanded,
|
||||
enter = expandEnter(),
|
||||
exit = collapseExit(),
|
||||
position = positionOf(rows.size, rowCount),
|
||||
expanded = customExpanded,
|
||||
onExpandedChange = { customExpanded = it },
|
||||
) {
|
||||
CustomReminderEditor(
|
||||
amountText = amountText,
|
||||
@@ -212,19 +189,6 @@ private fun CustomReminderEditor(
|
||||
)
|
||||
}
|
||||
|
||||
/** A short explanatory paragraph shown under a picker's title, above the rows,
|
||||
* on the same left margin as the rows themselves. */
|
||||
@Composable
|
||||
internal fun PickerDescription(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned
|
||||
* options (today / this week / this month) and the rolling windows (next 7 / 30
|
||||
@@ -265,17 +229,11 @@ fun AgendaRangePicker(
|
||||
}
|
||||
|
||||
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
|
||||
val isSelected = option == selected
|
||||
GroupedRow(
|
||||
title = agendaRangeLabel(option),
|
||||
summary = windowSummary(option),
|
||||
position = position,
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
checked = option == selected,
|
||||
onClick = {
|
||||
onSelect(option)
|
||||
onDismiss()
|
||||
@@ -284,7 +242,7 @@ fun AgendaRangePicker(
|
||||
}
|
||||
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||
PickerDescription(description)
|
||||
GroupedHint(description, verticalPadding = 8.dp)
|
||||
|
||||
// Calendar-aligned windows.
|
||||
calendarAligned.forEachIndexed { index, option ->
|
||||
@@ -297,7 +255,7 @@ fun AgendaRangePicker(
|
||||
rolling.forEachIndexed { index, option ->
|
||||
rangeRow(option, positionOf(index, rollingRowCount))
|
||||
}
|
||||
GroupedRow(
|
||||
CustomExpandRow(
|
||||
title = if (customSelected) {
|
||||
agendaRangeLabel(selected)
|
||||
} else {
|
||||
@@ -306,19 +264,10 @@ fun AgendaRangePicker(
|
||||
// An unset custom window has no day count yet, so the row stays
|
||||
// single-line until it does.
|
||||
summary = if (customSelected) windowSummary(selected) else null,
|
||||
position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount),
|
||||
selected = customSelected,
|
||||
trailing = if (customSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { customExpanded = !customExpanded },
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = customExpanded,
|
||||
enter = expandEnter(),
|
||||
exit = collapseExit(),
|
||||
position = positionOf(rolling.size, rollingRowCount),
|
||||
expanded = customExpanded,
|
||||
onExpandedChange = { customExpanded = it },
|
||||
checked = customSelected,
|
||||
) {
|
||||
CustomDaysEditor(
|
||||
amountText = amountText,
|
||||
@@ -396,17 +345,12 @@ fun DurationPicker(
|
||||
}
|
||||
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
|
||||
if (description != null) PickerDescription(description)
|
||||
if (description != null) GroupedHint(description, verticalPadding = 8.dp)
|
||||
if (inheritLabel != null) {
|
||||
GroupedRow(
|
||||
title = inheritLabel,
|
||||
position = Position.Alone,
|
||||
selected = inherits,
|
||||
trailing = if (inherits) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
checked = inherits,
|
||||
onClick = {
|
||||
onSelect(null)
|
||||
onDismiss()
|
||||
@@ -415,39 +359,22 @@ fun DurationPicker(
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
presets.forEachIndexed { index, minute ->
|
||||
val isSelected = minute == selected
|
||||
GroupedRow(
|
||||
title = label(minute),
|
||||
position = positionOf(index, rowCount),
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
checked = minute == selected,
|
||||
onClick = {
|
||||
onSelect(minute)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
// The Custom row connects downward into the editor card when expanded, so
|
||||
// the two read as one grouped container (the shared custom-expand pattern).
|
||||
GroupedRow(
|
||||
CustomExpandRow(
|
||||
title = custom?.let { label(it) } ?: stringResource(R.string.event_edit_reminder_custom),
|
||||
position = if (customExpanded) Position.Top else positionOf(presets.size, rowCount),
|
||||
selected = custom != null,
|
||||
trailing = if (custom != null) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { customExpanded = !customExpanded },
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = customExpanded,
|
||||
enter = expandEnter(),
|
||||
exit = collapseExit(),
|
||||
position = positionOf(presets.size, rowCount),
|
||||
expanded = customExpanded,
|
||||
onExpandedChange = { customExpanded = it },
|
||||
checked = custom != null,
|
||||
) {
|
||||
CustomDurationEditor(
|
||||
amountText = amountText,
|
||||
|
||||
@@ -10,6 +10,8 @@ import androidx.compose.ui.text.font.FontStyle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
||||
import de.jeanlucmakiola.calendula.domain.occurrencesSpanYears
|
||||
import de.jeanlucmakiola.calendula.domain.rruleParts
|
||||
import de.jeanlucmakiola.calendula.domain.ics.parseBasicDate
|
||||
import de.jeanlucmakiola.calendula.domain.upcomingOccurrences
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
@@ -32,10 +34,7 @@ import java.util.Locale
|
||||
*/
|
||||
@Composable
|
||||
fun recurrenceText(rrule: String, locale: Locale): AnnotatedString {
|
||||
val parts = rrule.removePrefix("RRULE:").split(';').mapNotNull { token ->
|
||||
val eq = token.indexOf('=')
|
||||
if (eq <= 0) null else token.substring(0, eq).uppercase() to token.substring(eq + 1)
|
||||
}.toMap()
|
||||
val parts = rruleParts(rrule).mapNotNull { part -> part.key?.let { it to part.value } }.toMap()
|
||||
|
||||
val freq = parts["FREQ"]?.uppercase()
|
||||
val interval = parts["INTERVAL"]?.toIntOrNull() ?: 1
|
||||
@@ -171,12 +170,5 @@ internal fun untilLocalDate(raw: String, zone: ZoneId = ZoneId.systemDefault()):
|
||||
.atOffset(ZoneOffset.UTC)
|
||||
.atZoneSameInstant(zone)
|
||||
.toLocalDate()
|
||||
}.recoverCatching {
|
||||
val digits = value.takeWhile { it.isDigit() }
|
||||
LocalDate.of(
|
||||
digits.substring(0, 4).toInt(),
|
||||
digits.substring(4, 6).toInt(),
|
||||
digits.substring(6, 8).toInt(),
|
||||
)
|
||||
}.getOrNull()
|
||||
}.getOrNull() ?: parseBasicDate(value.takeWhile { it.isDigit() })?.toJavaLocalDate()
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ package de.jeanlucmakiola.calendula.ui.common
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.time.ZoneSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||
@@ -15,26 +15,28 @@ import de.jeanlucmakiola.calendula.domain.allowsEventMove
|
||||
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
||||
import de.jeanlucmakiola.calendula.domain.problems
|
||||
import de.jeanlucmakiola.calendula.domain.realignRecurrence
|
||||
import de.jeanlucmakiola.calendula.domain.resizedTo
|
||||
import de.jeanlucmakiola.calendula.domain.endInstant
|
||||
import de.jeanlucmakiola.calendula.domain.startInstant
|
||||
import de.jeanlucmakiola.calendula.domain.resolvedZone
|
||||
import de.jeanlucmakiola.calendula.domain.shiftedByDays
|
||||
import de.jeanlucmakiola.calendula.domain.shiftedTo
|
||||
import de.jeanlucmakiola.calendula.domain.toEditForm
|
||||
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Instant
|
||||
@@ -54,6 +56,9 @@ sealed interface MoveTarget {
|
||||
* disagree by a day over which zone the event's first day is resolved in.
|
||||
*/
|
||||
data class ByDays(val days: Int) : MoveTarget
|
||||
|
||||
/** New start and end instants — a timeline resize, which changes the length. */
|
||||
data class Span(val start: Instant, val end: Instant) : MoveTarget
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,7 +79,7 @@ data class MoveRequest(
|
||||
* re-derive (`BYDAY=MO,WE`, `2TH`, …), or the shift wouldn't carry the anchor
|
||||
* across the same midnights the occurrence crossed.
|
||||
*/
|
||||
data class MoveScopePrompt(val occurrenceOnly: Boolean)
|
||||
data class MoveScopePrompt(val occurrenceOnly: Boolean, val resize: Boolean = false)
|
||||
|
||||
/** The inverse of a completed move, for the undo action. */
|
||||
data class MoveUndo(
|
||||
@@ -88,12 +93,14 @@ data class MoveUndo(
|
||||
sealed interface MoveOutcome {
|
||||
/**
|
||||
* Written. [startMillis] is where the dragged occurrence now begins, for the
|
||||
* confirmation message; [undo] is null when the write has no clean inverse.
|
||||
* confirmation message, and [endMillis] where it ends — set only for a resize,
|
||||
* whose message names both; [undo] is null when the write has no clean inverse.
|
||||
*/
|
||||
data class Moved(
|
||||
val startMillis: Long,
|
||||
val isAllDay: Boolean,
|
||||
val undo: MoveUndo?,
|
||||
val endMillis: Long? = null,
|
||||
) : MoveOutcome
|
||||
|
||||
data object Undone : MoveOutcome
|
||||
@@ -122,6 +129,7 @@ sealed interface MoveOutcome {
|
||||
class RescheduleViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
private val zoneSource: ZoneSource,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _scopePrompt = MutableStateFlow<MoveScopePrompt?>(null)
|
||||
@@ -179,9 +187,8 @@ class RescheduleViewModel @Inject constructor(
|
||||
.map { calendars -> calendars.filter { it.allowsEventMove }.map { it.id }.toSet() }
|
||||
.catch { emit(emptySet<Long>()) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = emptySet(),
|
||||
)
|
||||
|
||||
@@ -226,7 +233,10 @@ class RescheduleViewModel @Inject constructor(
|
||||
} else {
|
||||
// Still busy: the scope dialog is now the thing in flight.
|
||||
pending = prepared
|
||||
_scopePrompt.value = MoveScopePrompt(occurrenceOnly = !prepared.canRealign)
|
||||
_scopePrompt.value = MoveScopePrompt(
|
||||
occurrenceOnly = !prepared.canRealign,
|
||||
resize = request.target is MoveTarget.Span,
|
||||
)
|
||||
}
|
||||
}
|
||||
return true
|
||||
@@ -308,18 +318,19 @@ class RescheduleViewModel @Inject constructor(
|
||||
return null
|
||||
}
|
||||
|
||||
val zone = TimeZone.currentSystemDefault()
|
||||
val zone = zoneSource.current()
|
||||
val original = detail.toEditForm(request.beginMillis, request.endMillis, zone)
|
||||
val shifted = when (val target = request.target) {
|
||||
is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
|
||||
is MoveTarget.ByDays -> original.shiftedByDays(target.days, zone)
|
||||
is MoveTarget.Span -> original.resizedTo(target.start, target.end, zone)
|
||||
}
|
||||
// A zero-distance drop is not a write.
|
||||
if (shifted == original) return null
|
||||
|
||||
// The UNTIL check is deferred to the write, which knows how far the move
|
||||
// reaches and so which date has to clear it.
|
||||
if ((shifted.problems() - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) {
|
||||
if ((shifted.problems(zone) - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) {
|
||||
_outcome.value = MoveOutcome.Failed
|
||||
return null
|
||||
}
|
||||
@@ -396,6 +407,7 @@ class RescheduleViewModel @Inject constructor(
|
||||
startMillis = movedStartMillis(prepared),
|
||||
isAllDay = prepared.updated.isAllDay,
|
||||
undo = undoFor(prepared, scope),
|
||||
endMillis = resizedEndMillis(prepared),
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
@@ -480,6 +492,13 @@ class RescheduleViewModel @Inject constructor(
|
||||
null
|
||||
}
|
||||
|
||||
/** Where a resized occurrence now ends; null for a move, which keeps its length. */
|
||||
private fun resizedEndMillis(prepared: PreparedMove): Long? {
|
||||
if (prepared.request.target !is MoveTarget.Span) return null
|
||||
val form = prepared.updated
|
||||
return form.endInstant(form.resolvedZone(zoneSource.current())).toEpochMilliseconds()
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the dragged occurrence now begins, in the same anchoring the views
|
||||
* read dates back in: UTC midnight for an all-day event.
|
||||
@@ -489,8 +508,7 @@ class RescheduleViewModel @Inject constructor(
|
||||
return if (form.isAllDay) {
|
||||
form.start.date.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
|
||||
} else {
|
||||
form.start.toInstant(form.resolvedZone(TimeZone.currentSystemDefault()))
|
||||
.toEpochMilliseconds()
|
||||
form.startInstant(form.resolvedZone(zoneSource.current())).toEpochMilliseconds()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** The handle's touch target, centred on the edge it moves. */
|
||||
val ResizeHandleTarget = 48.dp
|
||||
|
||||
private val HANDLE_DOT = 16.dp
|
||||
private val HANDLE_RING = 2.dp
|
||||
|
||||
/**
|
||||
* A grip on one edge of a selected event (#141), dragged without a hold. No
|
||||
* semantics: TalkBack resizes through the event's edit action.
|
||||
*/
|
||||
@Composable
|
||||
fun ResizeHandle(
|
||||
color: Color,
|
||||
enabled: Boolean,
|
||||
onStart: (pointerInRoot: Offset) -> Unit,
|
||||
onMove: (pointerInRoot: Offset) -> Unit,
|
||||
onEnd: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val coordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
|
||||
val currentStart by rememberUpdatedState(onStart)
|
||||
val currentMove by rememberUpdatedState(onMove)
|
||||
val currentEnd by rememberUpdatedState(onEnd)
|
||||
val currentCancel by rememberUpdatedState(onCancel)
|
||||
// Not a key: turning it off mid-resize must not cancel that resize.
|
||||
val currentEnabled by rememberUpdatedState(enabled)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier
|
||||
.size(ResizeHandleTarget)
|
||||
.clearAndSetSemantics { }
|
||||
.onGloballyPositioned { coordinates[0] = it }
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown()
|
||||
if (!currentEnabled) return@awaitEachGesture
|
||||
down.consume()
|
||||
var started = false
|
||||
var ended = false
|
||||
try {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
||||
// Claimed by an ancestor that outranks us — the pinch.
|
||||
if (change.isConsumed) break
|
||||
if (event.changes.count { it.pressed } > 1) break
|
||||
change.consume()
|
||||
if (!change.pressed) {
|
||||
ended = started
|
||||
break
|
||||
}
|
||||
val root = coordinates[0]?.takeIf { it.isAttached }
|
||||
?.localToRoot(change.position) ?: break
|
||||
if (started) {
|
||||
currentMove(root)
|
||||
} else {
|
||||
started = true
|
||||
currentStart(root)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
when {
|
||||
ended -> currentEnd()
|
||||
started -> currentCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(HANDLE_DOT)
|
||||
.background(color, CircleShape)
|
||||
.border(HANDLE_RING, MaterialTheme.colorScheme.surface, CircleShape),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
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.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import java.time.ZoneId
|
||||
import java.util.Locale
|
||||
import kotlin.time.toJavaInstant
|
||||
|
||||
/**
|
||||
* The top bar while an event is selected (#141): says which, and when, with a
|
||||
* way out — the selection otherwise only shows on the grid and in the FAB.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SelectionTopBar(selection: EventSelection) {
|
||||
val event = selection.event
|
||||
// Follows a move or resize, so the times stay readable under the finger.
|
||||
val preview by remember(selection) { derivedStateOf { selection.preview() } }
|
||||
val shown = preview ?: event
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
val whenText = remember(shown.start, shown.end, use24Hour, locale) {
|
||||
formatSelectionWhen(shown, use24Hour, locale)
|
||||
}
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = selection.dismiss) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(
|
||||
if (selection.create != null) R.string.event_draft_discard else R.string.event_selection_close,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
text = event.title.ifBlank { stringResource(R.string.event_untitled) },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = whenText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
},
|
||||
// The header's own surface, as the view's bar has (#186).
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* "Fri, 9 Oct · 09:00–10:00", or with both days named for an event that runs
|
||||
* into another: "Fri, 9 Oct, 22:00 – Sat, 10 Oct, 02:00".
|
||||
*/
|
||||
private fun formatSelectionWhen(event: EventInstance, use24Hour: Boolean, locale: Locale): String {
|
||||
val zone = ZoneId.systemDefault()
|
||||
val start = event.start.toJavaInstant().atZone(zone)
|
||||
val end = event.end.toJavaInstant().atZone(zone)
|
||||
val day = localizedDateFormatter(locale, "EEEdMMM")
|
||||
val time = timeOfDayFormatter(use24Hour, locale)
|
||||
val endsSameDay = end.toLocalDate() == start.toLocalDate() ||
|
||||
(end.toLocalTime().toSecondOfDay() == 0 && end.toLocalDate() == start.toLocalDate().plusDays(1))
|
||||
return if (endsSameDay) {
|
||||
"${day.format(start)} · ${time.format(start)}–${time.format(end)}"
|
||||
} else {
|
||||
"${day.format(start)}, ${time.format(start)} – ${day.format(end)}, ${time.format(end)}"
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import de.jeanlucmakiola.calendula.domain.inRepeatedHour
|
||||
import de.jeanlucmakiola.calendula.domain.zoneAbbreviationAt
|
||||
import kotlinx.datetime.TimeZone
|
||||
import java.time.LocalTime
|
||||
import kotlin.time.Instant
|
||||
import kotlin.time.toJavaInstant
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
|
||||
/**
|
||||
* The resolved clock convention for the whole UI: `true` = 24-hour ("14:00"),
|
||||
@@ -32,6 +38,12 @@ fun formatTimeOfDay(hour: Int, minute: Int, is24Hour: Boolean, locale: Locale):
|
||||
LocalTime.of(hour.coerceIn(0, 23), minute.coerceIn(0, 59))
|
||||
.format(timeOfDayFormatter(is24Hour, locale))
|
||||
|
||||
/** [instant]'s wall-clock time in [zone], as [formatTimeOfDay] renders it. */
|
||||
fun formatTimeOfDay(instant: Instant, zone: TimeZone, is24Hour: Boolean, locale: Locale): String {
|
||||
val time = instant.toLocalDateTime(zone).time
|
||||
return formatTimeOfDay(time.hour, time.minute, is24Hour, locale)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format minutes-from-midnight (0..1440) as a time label. The end-of-day value
|
||||
* 1440 renders as "24:00" in 24h (its established reading) and as midnight
|
||||
@@ -68,3 +80,15 @@ fun formatHourLabel(hour: Int, is24Hour: Boolean, locale: Locale): String =
|
||||
}
|
||||
|
||||
private const val MINUTES_PER_DAY = 1_440
|
||||
|
||||
/**
|
||||
* [formatted] — [instant]'s time in [zone] — with the zone's abbreviation after
|
||||
* it ("2:30 AM CEST") when the fall-back hour repeats that time, the one case
|
||||
* where the time alone doesn't say which instant it is (#387).
|
||||
*/
|
||||
fun withRepeatedHourZone(formatted: String, instant: Instant, zone: TimeZone, locale: Locale): String {
|
||||
if (!instant.inRepeatedHour(zone)) return formatted
|
||||
val abbreviation = zoneAbbreviationAt(zone.id, locale, instant.toJavaInstant(), ::icuTimeZoneRegion)
|
||||
?: return formatted
|
||||
return "$formatted $abbreviation"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -39,11 +38,10 @@ import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
||||
import de.jeanlucmakiola.calendula.domain.timeZoneOptions
|
||||
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -118,19 +116,14 @@ fun TimeZonePickerDialog(
|
||||
title = stringResource(R.string.event_edit_timezone_device),
|
||||
summary = deviceSummary,
|
||||
position = Position.Alone,
|
||||
selected = selected == null,
|
||||
checked = selected == null,
|
||||
leading = { Icon(Icons.Default.Public, contentDescription = null) },
|
||||
trailing = if (selected == null) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { choose(null) },
|
||||
)
|
||||
}
|
||||
if (recentZones.isNotEmpty()) {
|
||||
item(key = "recent_header") {
|
||||
SectionHeader(stringResource(R.string.event_edit_timezone_recent))
|
||||
GroupedSectionHeader(stringResource(R.string.event_edit_timezone_recent))
|
||||
}
|
||||
itemsIndexed(
|
||||
items = recentZones,
|
||||
@@ -145,7 +138,7 @@ fun TimeZonePickerDialog(
|
||||
}
|
||||
}
|
||||
item(key = "all_header") {
|
||||
SectionHeader(stringResource(R.string.event_edit_timezone_all))
|
||||
GroupedSectionHeader(stringResource(R.string.event_edit_timezone_all))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,23 +227,6 @@ private fun SearchField(
|
||||
}
|
||||
}
|
||||
|
||||
/** A small primary-coloured group label, matching the settings screens. */
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(
|
||||
start = GroupedListInset,
|
||||
end = GroupedListInset,
|
||||
top = 16.dp,
|
||||
bottom = 4.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun ZoneRow(
|
||||
zone: TimeZoneOption,
|
||||
@@ -262,12 +238,7 @@ private fun ZoneRow(
|
||||
title = zone.label,
|
||||
summary = zoneDescriptor(zone),
|
||||
position = position,
|
||||
selected = selected,
|
||||
trailing = if (selected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
checked = selected,
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.MutatePriority
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -19,7 +18,10 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -27,6 +29,7 @@ import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
@@ -35,14 +38,25 @@ import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.instantIn
|
||||
import de.jeanlucmakiola.calendula.domain.repeatedHourOffsetAt
|
||||
import de.jeanlucmakiola.calendula.domain.repicked
|
||||
import de.jeanlucmakiola.calendula.domain.toFormTime
|
||||
import de.jeanlucmakiola.calendula.domain.resolveIn
|
||||
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
|
||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||
import de.jeanlucmakiola.calendula.ui.week.minutesPast
|
||||
import de.jeanlucmakiola.calendula.ui.week.wallClockEndMinutes
|
||||
import de.jeanlucmakiola.calendula.ui.week.wallClockMinutes
|
||||
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -50,13 +64,15 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.UtcOffset
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Start granularity a dragged block snaps to, matching the big calendar apps. */
|
||||
@@ -97,11 +113,28 @@ data class TimelineDrag(
|
||||
* drop lands, so it is dropped once the gesture ends ([finish]).
|
||||
*/
|
||||
val edgeOnly: Boolean = false,
|
||||
/**
|
||||
* The minute of its day the gutter badge names — the top edge, or for a
|
||||
* bottom-edge resize the end being dragged (#141).
|
||||
*/
|
||||
val markerMin: Int = startMin,
|
||||
) {
|
||||
/** What the snap haptics key off: one tick per changed slot, not per frame. */
|
||||
val slot: Pair<LocalDate, Int> get() = date to startMin
|
||||
/**
|
||||
* What the snap haptics key off: one tick per changed slot, not per frame.
|
||||
* Carries the length, which is all a bottom-edge resize changes.
|
||||
*/
|
||||
val slot: Triple<LocalDate, Int, Int> get() = Triple(date, startMin, eventSpanMin)
|
||||
}
|
||||
|
||||
/** The event a long press left selected (#141). */
|
||||
data class TimelineSelection(
|
||||
val event: EventInstance,
|
||||
/** Set from a move or resize of it until the grid shows where it landed. */
|
||||
val moved: Boolean = false,
|
||||
/** Selected by letting go of a lifted block, which then keeps that lift as it is. */
|
||||
val fromLift: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* One day column's share of a dragged event, ready to draw. Both halves of an
|
||||
* event crossing midnight are previewed, whichever half the finger picked up:
|
||||
@@ -248,6 +281,142 @@ data class TimelineDrop(
|
||||
val clipOffsetMin: Int = 0,
|
||||
)
|
||||
|
||||
/** The id a drafted block's stand-in event carries, which no provider row has (#153). */
|
||||
private const val DRAFT_ID = Long.MIN_VALUE
|
||||
|
||||
/** Whether this is the stand-in for a block drafted on an empty slot, not a real event. */
|
||||
val EventInstance.isDraft: Boolean get() = instanceId == DRAFT_ID
|
||||
|
||||
/** A drafted block's stand-in event, [minutes] long from [start]. */
|
||||
fun draftEvent(title: String, color: Int, start: Instant, minutes: Int): EventInstance = EventInstance(
|
||||
instanceId = DRAFT_ID,
|
||||
eventId = DRAFT_ID,
|
||||
calendarId = DRAFT_ID,
|
||||
title = title,
|
||||
start = start,
|
||||
end = start + minutes.minutes,
|
||||
isAllDay = false,
|
||||
color = color,
|
||||
location = null,
|
||||
)
|
||||
|
||||
/** Where a long press [minutes] down the column starts its draft, snapped down to the slot. */
|
||||
internal fun draftStartMin(minutes: Float): Int =
|
||||
(minutes / DRAG_SNAP_MINUTES).toInt().times(DRAG_SNAP_MINUTES)
|
||||
.coerceIn(0, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
|
||||
|
||||
/** The part of [draft] on [date] as a full-width block, or null if none of it falls there. */
|
||||
internal fun draftBlock(draft: EventInstance, date: LocalDate, zone: TimeZone): TimedBlock? {
|
||||
// Clipped to the day the way the grid lays out events, so it's drawn the same across a DST change (#387).
|
||||
// The end is exclusive, so a draft ending at midnight belongs to the day before.
|
||||
val start = maxOf(draft.start, date.atStartOfDayIn(zone))
|
||||
val end = minOf(draft.end, date.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone))
|
||||
if (end <= start) return null
|
||||
val startMin = wallClockMinutes(start, date, zone)
|
||||
val endMin = wallClockEndMinutes(startMin, start, end, date, zone).coerceAtMost(MINUTES_PER_DAY)
|
||||
return TimedBlock(draft, startMin, endMin, lane = 0, laneCount = 1)
|
||||
}
|
||||
|
||||
/** Which end of an event a resize handle moves (#141). */
|
||||
enum class ResizeEdge { Top, Bottom }
|
||||
|
||||
/** Where a finished resize asks its event to begin and end. */
|
||||
data class TimelineResizeDrop(val event: EventInstance, val start: Instant, val end: Instant)
|
||||
|
||||
/**
|
||||
* [event] resized by its [edge] to the span [startMin]..+[spanMin] from [date]'s
|
||||
* midnight. Only the held edge moves. The other keeps its own instant rather than
|
||||
* a round trip through the grid, where an end in the fall-back hour's repeat is
|
||||
* drawn to that hour's end, not at its own time (#387). In a repeated hour, the
|
||||
* held edge takes the pass that matches the direction it was dragged from where
|
||||
* it was drawn (see [repicked]). An end takes a pass the grid draws at the drop
|
||||
* point, so the block settles under the finger.
|
||||
*/
|
||||
internal fun resizedDrop(
|
||||
event: EventInstance,
|
||||
edge: ResizeEdge?,
|
||||
date: LocalDate,
|
||||
startMin: Int,
|
||||
spanMin: Int,
|
||||
zone: TimeZone,
|
||||
): TimelineResizeDrop {
|
||||
val drawn = wallClockSpan(event, zone)
|
||||
return TimelineResizeDrop(
|
||||
event = event,
|
||||
start = if (edge == ResizeEdge.Top) {
|
||||
event.start.toFormTime(zone).repicked(
|
||||
wallClockTime(date, startMin),
|
||||
zone,
|
||||
shownAt = wallClockTime(drawn.day, drawn.startMin),
|
||||
notAfter = event.end,
|
||||
).instantIn(zone)
|
||||
} else {
|
||||
event.start
|
||||
},
|
||||
end = if (edge == ResizeEdge.Bottom) {
|
||||
val picked = wallClockTime(date, startMin + spanMin)
|
||||
val pickedMin = (picked.date.toEpochDays() - drawn.day.toEpochDays()).toInt() * MINUTES_PER_DAY +
|
||||
picked.time.toSecondOfDay() / 60
|
||||
event.end.toFormTime(zone).repicked(
|
||||
picked,
|
||||
zone,
|
||||
shownAt = wallClockTime(drawn.day, drawn.endMin),
|
||||
notBefore = event.start,
|
||||
drawable = { wallClockEndMinutes(drawn.startMin, event.start, it, drawn.day, zone) == pickedMin },
|
||||
).instantIn(zone)
|
||||
} else {
|
||||
event.end
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The span after dragging [edge] to [edgeMin] in day [edgeDay], in minutes from the
|
||||
* start day's midnight. Snapped, and at least one snap step long.
|
||||
*/
|
||||
internal fun resizedSpan(
|
||||
edge: ResizeEdge,
|
||||
startMin: Int,
|
||||
endMin: Int,
|
||||
edgeMin: Float,
|
||||
edgeDay: Int? = null,
|
||||
): Pair<Int, Int> {
|
||||
// An edge dragged into a day past the other end changes nothing.
|
||||
if (edgeDay != null) {
|
||||
val endDay = (endMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0)
|
||||
val wrongWay = when (edge) {
|
||||
ResizeEdge.Top -> edgeDay > endDay
|
||||
ResizeEdge.Bottom -> edgeDay < startMin.floorDiv(MINUTES_PER_DAY)
|
||||
}
|
||||
if (wrongWay) return startMin to endMin
|
||||
}
|
||||
val snapped = snapToSlot(edgeMin)
|
||||
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)
|
||||
}
|
||||
|
||||
/** How far past its column's side the finger has to go before a resize changes day. */
|
||||
private const val COLUMN_HYSTERESIS = 0.25f
|
||||
|
||||
/** The column under [position], kept at [held] until [COLUMN_HYSTERESIS] into another. */
|
||||
internal fun stickyColumn(position: Float, held: Int?, lastIndex: Int): Int {
|
||||
val under = position.toInt().coerceIn(0, lastIndex)
|
||||
if (held == null) return under
|
||||
val stays = position >= held - COLUMN_HYSTERESIS && position < held + 1 + COLUMN_HYSTERESIS
|
||||
return if (stays) held else under
|
||||
}
|
||||
|
||||
/**
|
||||
* The timeline's live geometry, republished on every layout. Plain fields rather
|
||||
* than snapshot state: it changes on every scroll frame, and the drag loop reads
|
||||
@@ -302,6 +471,10 @@ class TimelineDragController {
|
||||
var settling: TimelineDrag? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/** How many real drops this controller has handed back. */
|
||||
var drops by mutableIntStateOf(0)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Which block is lifted, and whether anything is. Separate snapshot state
|
||||
* from [drag], which changes every frame, so blocks that only need "am I the
|
||||
@@ -320,10 +493,92 @@ class TimelineDragController {
|
||||
*/
|
||||
private var settledOrigin: Pair<Long, Instant>? by mutableStateOf(null)
|
||||
|
||||
/** How long a block drafted on an empty slot starts out, in minutes (#153). */
|
||||
var draftMinutes: Int = SettingsPrefs.DEFAULT_EVENT_DURATION
|
||||
|
||||
/** The selected event, if any — see [TimelineSelection]. */
|
||||
var selection: TimelineSelection? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
fun select(event: EventInstance, moved: Boolean = false, fromLift: Boolean = false) {
|
||||
if (selection?.event?.instanceId != event.instanceId) selectionCovered = false
|
||||
selection = TimelineSelection(event, moved, fromLift)
|
||||
}
|
||||
|
||||
fun deselect() {
|
||||
if (selection?.event?.isDraft == true && settling?.event?.isDraft == true) release()
|
||||
selection = null
|
||||
selectionCovered = false
|
||||
}
|
||||
|
||||
/** The events of the blocks on screen that show the selection; none for a while ends it. */
|
||||
val selectedCandidates = mutableStateListOf<EventInstance>()
|
||||
|
||||
fun noteSelectedBlock(event: EventInstance, shown: Boolean) {
|
||||
if (shown) selectedCandidates.add(event) else selectedCandidates.remove(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Once a move of the selected event has settled, point the selection at the
|
||||
* row it landed as. A new event id wins: a single moved occurrence is written
|
||||
* as an exception, and another occurrence of its series may share its time.
|
||||
*/
|
||||
fun settleMovedSelection() {
|
||||
val current = selection?.takeIf { it.moved } ?: return
|
||||
if (isDragging || settling != null) return
|
||||
val landed = selectedCandidates.filter { it.instanceId != current.event.instanceId }
|
||||
// Only the original row left: the move was abandoned, and it still stands.
|
||||
val chosen = landed.firstOrNull { it.eventId != current.event.eventId }
|
||||
?: landed.firstOrNull() ?: selectedCandidates.firstOrNull() ?: return
|
||||
selection = current.copy(event = chosen, moved = false)
|
||||
}
|
||||
|
||||
/** Whether the selected block's bottom sits where the action bar would cover it. */
|
||||
var selectionCovered: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Whether [block] is the selected event: by instance, by series and start, and
|
||||
* just after a move by title and times, since a moved occurrence gets new ids.
|
||||
*/
|
||||
fun isSelected(block: TimedBlock): Boolean {
|
||||
val current = selection ?: return false
|
||||
val selected = current.event
|
||||
val event = block.event
|
||||
return event.instanceId == selected.instanceId ||
|
||||
(event.eventId == selected.eventId && event.start == selected.start) ||
|
||||
(
|
||||
current.moved &&
|
||||
event.calendarId == selected.calendarId &&
|
||||
event.title.isNotBlank() &&
|
||||
event.title == selected.title &&
|
||||
event.start == selected.start &&
|
||||
event.end == selected.end
|
||||
)
|
||||
}
|
||||
|
||||
/** Point the selection at [block]'s event, so the bar acts on the row now there. */
|
||||
fun adoptSelection(block: TimedBlock) {
|
||||
val current = selection?.takeIf { !it.moved } ?: return
|
||||
if (current.event != block.event) selection = current.copy(event = block.event)
|
||||
}
|
||||
|
||||
/** Whether a finger is on a block right now — [settling] is not dragging. */
|
||||
var isDragging: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
/** Whether the finger has moved since the pickup; only then may the view auto-scroll. */
|
||||
var pointerMoved: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
/** Touch slop, past which the finger counts as having moved. */
|
||||
var slopPx: Float = 0f
|
||||
|
||||
private var pickupPointer = Offset.Zero
|
||||
|
||||
/** The end a settling resize asked for, which the grid has to show before it settles. */
|
||||
private var settledEnd: Instant? = null
|
||||
|
||||
/**
|
||||
* Whether the grid itself now draws the settled drop at its landing slot.
|
||||
* The copy may only be handed back once this is true, or the source ghost
|
||||
@@ -332,6 +587,10 @@ class TimelineDragController {
|
||||
var settledOnGrid: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
/** Whether the settling drop is the selected event, whose block lands still lifted. */
|
||||
var settlingSelected: Boolean = false
|
||||
private set
|
||||
|
||||
/**
|
||||
* The abandoned-move counter, read at the drop so the mark can't be a
|
||||
* composition behind the tick it is compared against.
|
||||
@@ -353,6 +612,10 @@ class TimelineDragController {
|
||||
* finger is on; this is what turns where it lands back into an event start.
|
||||
*/
|
||||
private var clipOffsetMin = 0
|
||||
private var dragZone: TimeZone? = null
|
||||
private var dragDuration = Duration.ZERO
|
||||
/** The dragged event's pass of a repeated hour, worked out once per drag (#387). */
|
||||
private var dragKept: UtcOffset? = null
|
||||
|
||||
/** What the picked-up block drew its title over — see [TimelineDrag.titleLines]. */
|
||||
private var titleLines = 0
|
||||
@@ -361,44 +624,73 @@ class TimelineDragController {
|
||||
* The slot the block already occupied when it was picked up — not the same
|
||||
* as its start, since the target snaps to the grid (09:07 lifts to 09:00).
|
||||
*/
|
||||
private var originSlot: Pair<LocalDate, Int>? = null
|
||||
private var originSlot: Triple<LocalDate, Int, Int>? = null
|
||||
|
||||
/** The resize in progress, or null for a plain drag (#141). */
|
||||
private var resize: ResizeSession? = null
|
||||
|
||||
fun begin(
|
||||
block: TimedBlock,
|
||||
clipOffsetMin: Int,
|
||||
zone: TimeZone,
|
||||
titleLines: Int,
|
||||
pointerInRoot: Offset,
|
||||
blockInRoot: Offset,
|
||||
) {
|
||||
// The selected event keeps its selection through a drag; any other ends it.
|
||||
if (!isSelected(block)) deselect()
|
||||
source = block
|
||||
this.clipOffsetMin = clipOffsetMin
|
||||
dragZone = zone
|
||||
dragDuration = (block.event.end - block.event.start).coerceAtLeast(Duration.ZERO)
|
||||
dragKept = zone.repeatedHourOffsetAt(block.event.start)
|
||||
this.titleLines = titleLines
|
||||
settling = null
|
||||
isDragging = true
|
||||
liftedInstanceId = block.event.instanceId
|
||||
grab = pointerInRoot - blockInRoot
|
||||
pointer = pointerInRoot
|
||||
pickupPointer = pointerInRoot
|
||||
pointerMoved = false
|
||||
originSlot = null
|
||||
recompute()
|
||||
originSlot = drag?.slot
|
||||
}
|
||||
|
||||
/**
|
||||
* Draft a block on an empty slot and lift it at once (#153). It is selected
|
||||
* from the start, so it keeps its handles and bar once the finger lets go.
|
||||
*/
|
||||
fun beginDraft(block: TimedBlock, zone: TimeZone, pointerInRoot: Offset, blockInRoot: Offset) {
|
||||
deselect()
|
||||
select(block.event)
|
||||
begin(block, clipOffsetMin = 0, zone, titleLines = 1, pointerInRoot = pointerInRoot, blockInRoot = blockInRoot)
|
||||
}
|
||||
|
||||
fun move(pointerInRoot: Offset) {
|
||||
pointer = pointerInRoot
|
||||
if (!pointerMoved && (pointerInRoot - pickupPointer).getDistance() > slopPx) pointerMoved = true
|
||||
recompute()
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
resize = null
|
||||
source = null
|
||||
clipOffsetMin = 0
|
||||
dragZone = null
|
||||
dragDuration = Duration.ZERO
|
||||
dragKept = null
|
||||
titleLines = 0
|
||||
isDragging = false
|
||||
pointerMoved = false
|
||||
liftedInstanceId = null
|
||||
originSlot = null
|
||||
drag = null
|
||||
settling = null
|
||||
settledOnGrid = false
|
||||
settledOrigin = null
|
||||
settledEnd = null
|
||||
settlingSelected = false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -429,8 +721,11 @@ class TimelineDragController {
|
||||
val offset = (date.toEpochDays() - landed.date.toEpochDays()).toInt()
|
||||
val slice = dragSlices(landed.eventStartMin, landed.eventSpanMin, offset..offset)
|
||||
.firstOrNull() ?: return
|
||||
val end = settledEnd
|
||||
settledOnGrid = blocks.any { block ->
|
||||
block.startMin == slice.startMin &&
|
||||
// A resize may keep its start, so its end has to have landed too.
|
||||
(end == null || block.event.end == end) &&
|
||||
(
|
||||
block.event.eventId == landed.event.eventId ||
|
||||
(
|
||||
@@ -452,6 +747,15 @@ class TimelineDragController {
|
||||
val clipOffset = clipOffsetMin
|
||||
cancel()
|
||||
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
|
||||
// [noteGrid] can never seat it and it would sit at the top of today for
|
||||
@@ -460,7 +764,139 @@ class TimelineDragController {
|
||||
liftedInstanceId = landed.event.instanceId
|
||||
settledOrigin = landed.event.eventId to landed.event.start
|
||||
settledAbandonedMark = abandonedTicks?.value ?: 0
|
||||
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset)
|
||||
settledEnd = end
|
||||
drops++
|
||||
}
|
||||
|
||||
/**
|
||||
* Grab one edge of [block] to resize it (#141). The edge follows the finger
|
||||
* across days as a drag does; the other stays where it is.
|
||||
*/
|
||||
fun beginResize(
|
||||
block: TimedBlock,
|
||||
edge: ResizeEdge,
|
||||
zone: TimeZone,
|
||||
titleLines: Int,
|
||||
pointerInRoot: Offset,
|
||||
) {
|
||||
val span = wallClockSpan(block.event, zone)
|
||||
val session = ResizeSession(edge, span.day, span.startMin, span.endMin)
|
||||
source = block
|
||||
resize = session
|
||||
clipOffsetMin = 0
|
||||
this.titleLines = titleLines
|
||||
settling = null
|
||||
isDragging = true
|
||||
pointerMoved = true
|
||||
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 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
|
||||
originSlot = null
|
||||
recompute()
|
||||
originSlot = drag?.slot
|
||||
}
|
||||
|
||||
/** The slot the gesture would land on, changing once per snap rather than per frame. */
|
||||
private val landingSlot by derivedStateOf { drag?.slot }
|
||||
|
||||
/**
|
||||
* The dragged event at the times letting go now would give it, worked out as
|
||||
* [finish] and [finishResize] do. Null while it is still on its own slot (#141).
|
||||
*/
|
||||
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
|
||||
if (edge != null) {
|
||||
val drop = resizedDrop(event, edge, date, startMin, spanMin, zone)
|
||||
return event.copy(start = drop.start, end = drop.end)
|
||||
}
|
||||
val start = TimelineDrop(event, date, startMin, clipOffsetMin).startInstant(zone)
|
||||
return event.copy(start = start, end = start + dragDuration)
|
||||
}
|
||||
|
||||
/**
|
||||
* End a resize, handing back the event's new span — null when it never
|
||||
* resolved or came back to the length it started at. Settles as [finish] does.
|
||||
*/
|
||||
fun finishResize(zone: TimeZone): TimelineResizeDrop? {
|
||||
val landed = drag
|
||||
val origin = originSlot
|
||||
val edge = resize?.edge
|
||||
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)
|
||||
return drop
|
||||
}
|
||||
|
||||
/**
|
||||
* 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? {
|
||||
val grid = geometry.grid?.takeIf { it.isAttached } ?: 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(
|
||||
position = (pointer.x - origin.x) / columnPx,
|
||||
held = session.column,
|
||||
lastIndex = days.lastIndex,
|
||||
)
|
||||
session.column = held
|
||||
val dayOffset = (days[column(held)].toEpochDays() - session.day.toEpochDays()).toInt()
|
||||
session.edgeDay = dayOffset
|
||||
return dayOffset * MINUTES_PER_DAY + (pointer.y - origin.y) / hourPx * 60f
|
||||
}
|
||||
|
||||
private fun recomputeResize(session: ResizeSession) {
|
||||
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 days = geometry.days
|
||||
val origin = geometry.grid?.takeIf { it.isAttached }?.positionInRoot() ?: return
|
||||
// Which column the start day sits in, possibly off either end of the view.
|
||||
val startIndex = (day.toEpochDays() - days.first().toEpochDays()).toInt()
|
||||
val visible = -startIndex..days.lastIndex - startIndex
|
||||
val slices = dragSlices(start, end - start, within = visible)
|
||||
drag = TimelineDrag(
|
||||
event = block.event,
|
||||
date = day,
|
||||
startMin = start,
|
||||
eventStartMin = start,
|
||||
eventSpanMin = end - start,
|
||||
titleLines = titleLines,
|
||||
pieces = pieces(slices, startIndex, origin),
|
||||
edgeOnly = slices.isEmpty(),
|
||||
markerMin = if (edge == ResizeEdge.Top) {
|
||||
start.mod(MINUTES_PER_DAY)
|
||||
} else {
|
||||
end - (end - 1).floorDiv(MINUTES_PER_DAY) * MINUTES_PER_DAY
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Note whether the action bar would cover the selected block's bottom handle (#141). */
|
||||
fun noteSelectedBottom(block: LayoutCoordinates, extraPx: Float, clearancePx: Float) {
|
||||
val viewport = geometry.viewport?.takeIf { it.isAttached } ?: return
|
||||
if (!block.isAttached) return
|
||||
val port = viewport.boundsInRoot()
|
||||
val bottom = block.positionInRoot().y + block.size.height + extraPx
|
||||
val covered = bottom > port.bottom - clearancePx
|
||||
if (covered != selectionCovered) selectionCovered = covered
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -477,6 +913,8 @@ class TimelineDragController {
|
||||
fun release() {
|
||||
settling = null
|
||||
settledOnGrid = false
|
||||
settledEnd = null
|
||||
settlingSelected = false
|
||||
handOver()
|
||||
}
|
||||
|
||||
@@ -486,6 +924,10 @@ class TimelineDragController {
|
||||
* no move events while auto-scroll walks the content underneath it.
|
||||
*/
|
||||
fun recompute() {
|
||||
resize?.let {
|
||||
recomputeResize(it)
|
||||
return
|
||||
}
|
||||
val block = source ?: return
|
||||
val grid = geometry.grid?.takeIf { it.isAttached } ?: return
|
||||
val hourPx = geometry.hourPx
|
||||
@@ -495,14 +937,22 @@ class TimelineDragController {
|
||||
|
||||
val origin = grid.positionInRoot()
|
||||
val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f
|
||||
val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
|
||||
val eventSpan = (block.event.end - block.event.start).inWholeMinutes.toInt()
|
||||
.coerceAtLeast(0)
|
||||
val floor = dragFloorMin(clipOffsetMin, eventSpan)
|
||||
val startMin = snapped.coerceIn(floor, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
|
||||
val eventStartMin = startMin - clipOffsetMin
|
||||
val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex)
|
||||
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
|
||||
val snapped = snapToSlot(rawMinutes)
|
||||
val zone = dragZone ?: return
|
||||
val dayIndex = column(((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex))
|
||||
val target = days[dayIndex]
|
||||
val keep = dragKept
|
||||
// Measured where it would land: the drop keeps the real duration, which in
|
||||
// wall clock gains or loses the hour of a DST change it crosses (#387).
|
||||
val floor = dragFloorMin(
|
||||
clipOffsetMin,
|
||||
wallClockSpanMinutes(target, snapped - clipOffsetMin, dragDuration, zone, keep),
|
||||
)
|
||||
val held = snapped.coerceIn(floor, MINUTES_PER_DAY - DRAG_SNAP_MINUTES) - clipOffsetMin
|
||||
// A start in the spring-forward gap lands an hour on; show it there.
|
||||
val eventStartMin = wallClockInstant(target, held, zone, keep).minutesPast(target, zone)
|
||||
val startMin = (eventStartMin + clipOffsetMin).coerceAtMost(MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
|
||||
val eventSpan = wallClockSpanMinutes(target, eventStartMin, dragDuration, zone, keep)
|
||||
val visible = -dayIndex..days.lastIndex - dayIndex
|
||||
val onGrid = dragSlices(eventStartMin, eventSpan, within = visible)
|
||||
val slices = onGrid.ifEmpty {
|
||||
@@ -517,28 +967,34 @@ 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 = 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,
|
||||
)
|
||||
},
|
||||
pieces = pieces(slices, dayIndex, origin),
|
||||
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
|
||||
@@ -571,12 +1027,34 @@ 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() }
|
||||
val density = LocalDensity.current
|
||||
controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() }
|
||||
controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() }
|
||||
controller.slopPx = LocalViewConfiguration.current.touchSlop
|
||||
controller.abandonedTicks = LocalEventMove.current?.abandoned
|
||||
return controller
|
||||
}
|
||||
@@ -589,10 +1067,7 @@ fun rememberTimelineDragController(): TimelineDragController {
|
||||
* so the offset has to come back off at the drop (#253).
|
||||
*/
|
||||
fun TimedBlock.clipOffsetMinutes(day: LocalDate, zone: TimeZone): Int {
|
||||
val start = event.start.toLocalDateTime(zone)
|
||||
val days = (day.toEpochDays() - start.date.toEpochDays()).toInt()
|
||||
val minutes = days * MINUTES_PER_DAY + startMin - start.time.toSecondOfDay() / 60
|
||||
return minutes.coerceAtLeast(0)
|
||||
return (startMin - event.start.minutesPast(day, zone)).coerceAtLeast(0)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -613,15 +1088,58 @@ fun TimedBlock.continuesAfter(day: LocalDate, zone: TimeZone): Boolean =
|
||||
* of its own; `toInstant` resolves it forward by the missing hour, and the block
|
||||
* visibly settles there.
|
||||
*/
|
||||
fun TimelineDrop.startInstant(zone: TimeZone): Instant {
|
||||
// Wall clock throughout, and past this day at either end: a block's top edge
|
||||
// can be dragged above its midnight and a tail's below the next one, so the
|
||||
// start is resolved as a day plus a minute of it rather than as an offset
|
||||
// from an instant — which would shift by an hour across a DST boundary.
|
||||
val fromMidnight = startMin - clipOffsetMin
|
||||
val day = date.plus(fromMidnight.floorDiv(MINUTES_PER_DAY), DateTimeUnit.DAY)
|
||||
val minute = fromMidnight.mod(MINUTES_PER_DAY)
|
||||
return LocalDateTime(day, LocalTime(minute / 60, minute % 60)).toInstant(zone)
|
||||
fun TimelineDrop.startInstant(zone: TimeZone): Instant =
|
||||
wallClockInstant(date, startMin - clipOffsetMin, zone, kept = zone.repeatedHourOffsetAt(event.start))
|
||||
|
||||
/**
|
||||
* The wall-clock minutes an event lasting [duration] covers when it starts
|
||||
* [startMin] past [date]'s midnight in [zone] — its real length, give or take a
|
||||
* DST change in between (#387).
|
||||
*/
|
||||
internal fun wallClockSpanMinutes(
|
||||
date: LocalDate,
|
||||
startMin: Int,
|
||||
duration: Duration,
|
||||
zone: TimeZone,
|
||||
kept: UtcOffset? = null,
|
||||
): Int {
|
||||
val start = wallClockInstant(date, startMin, zone, kept)
|
||||
return wallClockEndMinutes(startMin, start, start + duration, date, zone) - startMin
|
||||
}
|
||||
|
||||
/**
|
||||
* [minutes] past [date]'s midnight, either way past it, as wall clock in [zone]
|
||||
* (DST-safe). A time the fall-back hour repeats lands on its earlier pass, unless
|
||||
* [kept] is its later one: an event there stays on it (#387). Pass
|
||||
* [repeatedHourOffsetAt] of the event's time, which is null outside the hour —
|
||||
* such an instant says nothing about which pass the grid slot means, so a drop
|
||||
* never depends on the season the event came from.
|
||||
*/
|
||||
fun wallClockInstant(date: LocalDate, minutes: Int, zone: TimeZone, kept: UtcOffset? = null): Instant =
|
||||
wallClockTime(date, minutes).resolveIn(zone, kept)
|
||||
|
||||
/** The wall-clock time [minutes] past [date]'s midnight, on a later day past 1440. */
|
||||
fun wallClockTime(date: LocalDate, minutes: Int): LocalDateTime {
|
||||
val day = date.plus(minutes.floorDiv(MINUTES_PER_DAY), DateTimeUnit.DAY)
|
||||
val minute = minutes.mod(MINUTES_PER_DAY)
|
||||
return LocalDateTime(day, LocalTime(minute / 60, minute % 60))
|
||||
}
|
||||
|
||||
/** An event's span as wall-clock minutes from its start day's midnight. */
|
||||
data class WallClockSpan(val day: LocalDate, val startMin: Int, val endMin: Int)
|
||||
|
||||
/**
|
||||
* [event]'s [WallClockSpan], its end past 1440 when it ends on a later day
|
||||
* (DST-safe). One ending in the fall-back hour's repeat ends where that hour does.
|
||||
*/
|
||||
fun wallClockSpan(event: EventInstance, zone: TimeZone): WallClockSpan {
|
||||
val start = event.start.toLocalDateTime(zone)
|
||||
val startMin = start.time.toSecondOfDay() / 60
|
||||
return WallClockSpan(
|
||||
start.date,
|
||||
startMin,
|
||||
wallClockEndMinutes(startMin, event.start, event.end, start.date, zone),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -651,7 +1169,7 @@ const val SETTLE_FADE_MILLIS: Int = 250
|
||||
fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) {
|
||||
var origin by remember { mutableStateOf(Offset.Zero) }
|
||||
val density = LocalDensity.current
|
||||
val dark = isSystemInDarkTheme()
|
||||
val dark = LocalFloretDarkTheme.current
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
@@ -659,9 +1177,11 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
||||
|
||||
// Both live here rather than beside the controller: they read [drag], which
|
||||
// changes every frame, and this composable is the one that is meant to.
|
||||
DragSnapHaptics(controller.drag?.slot)
|
||||
LaunchedEffect(controller.isDragging) {
|
||||
if (controller.isDragging) controller.autoScroll()
|
||||
DragSnapHaptics(controller.drag?.slot, controller.drops)
|
||||
// Only once the finger has moved: a long press held near an edge must not
|
||||
// scroll the drop target out from under a finger that only means to select.
|
||||
LaunchedEffect(controller.isDragging && controller.pointerMoved) {
|
||||
if (controller.isDragging && controller.pointerMoved) controller.autoScroll()
|
||||
}
|
||||
// Hold the landed copy until the write is done — including the time a
|
||||
// recurring drop's scope dialog is up — and then until the grid draws the
|
||||
@@ -693,7 +1213,8 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
||||
// Landed: the copy sinks back to the grid's own plane while the write runs.
|
||||
val landed = controller.drag == null
|
||||
val lift by animateFloatAsState(
|
||||
targetValue = if (landed || reduceMotion) 0f else 1f,
|
||||
// A selected event's block is lifted too: sinking first made it shrink, then grow back.
|
||||
targetValue = if (reduceMotion || (landed && !controller.settlingSelected)) 0f else 1f,
|
||||
label = "drag-lift",
|
||||
)
|
||||
val copyAlpha by animateFloatAsState(
|
||||
@@ -870,7 +1391,7 @@ private fun DragCopy(
|
||||
Text(
|
||||
text = label,
|
||||
// As the block it lifted off sets its own time (#219).
|
||||
style = MaterialTheme.typography.labelSmall.asEventTime(),
|
||||
style = rememberPackedLines(MaterialTheme.typography.labelSmall.asEventTime()),
|
||||
maxLines = timeMaxLines,
|
||||
overflow = overflow.overflow,
|
||||
softWrap = overflow.softWrap,
|
||||
@@ -880,3 +1401,30 @@ private fun DragCopy(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** How far a selected block is lifted, 0 to 1 (#141); [snap] takes it over at once. */
|
||||
@Composable
|
||||
fun selectionLift(selected: Boolean, snap: Boolean = false): Float {
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
val spec = MaterialTheme.motionScheme.fastSpatialSpec<Float>()
|
||||
val raise = remember { Animatable(if (selected) 1f else 0f) }
|
||||
LaunchedEffect(selected) {
|
||||
val target = if (selected) 1f else 0f
|
||||
if (reduceMotion || (selected && snap)) raise.snapTo(target) else raise.animateTo(target, spec)
|
||||
}
|
||||
// The effect runs a frame late, and that frame would show the block unlifted.
|
||||
return if (selected && snap) 1f else raise.value
|
||||
}
|
||||
|
||||
/**
|
||||
* A selected block raised off the grid, the way a dragged copy is: scaled from
|
||||
* its top edge so the text doesn't creep, with a shadow in the block's shape.
|
||||
*/
|
||||
fun Modifier.selectionLift(raise: Float, shape: Shape): Modifier = graphicsLayer {
|
||||
transformOrigin = TransformOrigin(0.5f, 0f)
|
||||
scaleX = 1f + 0.02f * raise
|
||||
scaleY = 1f + 0.02f * raise
|
||||
shadowElevation = 6.dp.toPx() * raise
|
||||
this.shape = shape
|
||||
clip = false
|
||||
}
|
||||
|
||||
@@ -16,8 +16,6 @@ data class TimelineHours(
|
||||
val startHour: Int = DEFAULT_START_HOUR,
|
||||
val endHour: Int = DEFAULT_END_HOUR,
|
||||
) {
|
||||
val span: Int get() = endHour - startHour
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_START_HOUR = 7
|
||||
const val DEFAULT_END_HOUR = 22
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The viewport a view preview pretends to be: a phone's width by the height a
|
||||
* calendar view gets under the top bar. Scaling every preview from one fixed
|
||||
* height keeps them comparable — each shows the same slice of screen.
|
||||
*/
|
||||
internal val PREVIEW_VIEWPORT_HEIGHT = 440.dp
|
||||
|
||||
/**
|
||||
* Renders [content] at a full phone viewport and shrinks the finished layout
|
||||
* into a box [height] tall, for the settings and onboarding choosers. Callers
|
||||
* pass the *real* view composables, so a preview cannot drift from what it
|
||||
* depicts.
|
||||
*
|
||||
* The [layout] below ignores the incoming constraints so the view measures at a
|
||||
* phone viewport, and a [graphicsLayer] scale shrinks the result.
|
||||
* `Modifier.requiredSize` is the obvious alternative but *centres* content that
|
||||
* overflows its constraints, leaving only the bottom-right corner in the clip.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ScaledViewPreview(
|
||||
height: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||
val scale = height.value / PREVIEW_VIEWPORT_HEIGHT.value
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clipToBounds()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
// A preview is a picture, not a control: swallow touches before the
|
||||
// view's own clickables see them, and give screen readers one label
|
||||
// instead of six weeks of day cells.
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitPointerEvent(PointerEventPass.Initial).changes
|
||||
.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.clearAndSetSemantics { }
|
||||
// Measure at a full phone viewport but report the scaled size, so the
|
||||
// node occupies exactly what it draws.
|
||||
.layout { measurable, _ ->
|
||||
val fullWidth = screenWidth.roundToPx()
|
||||
val fullHeight = PREVIEW_VIEWPORT_HEIGHT.roundToPx()
|
||||
val placeable = measurable.measure(Constraints.fixed(fullWidth, fullHeight))
|
||||
layout((fullWidth * scale).roundToInt(), (fullHeight * scale).roundToInt()) {
|
||||
placeable.place(0, 0)
|
||||
}
|
||||
}
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
transformOrigin = TransformOrigin(0f, 0f)
|
||||
},
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The stage every preview picker sets its preview on, cross-fading as [selected]
|
||||
* changes. A preview draws the app's own surface, so a tonal plate gives it the
|
||||
* edge it would otherwise lack on a chooser screen; the plate takes the width
|
||||
* and outer radius of the grouped rows underneath so the two read as one stack.
|
||||
*
|
||||
* Only vertical spacing is the caller's to pass — the horizontal inset is the
|
||||
* rows' own, and the alignment is the point.
|
||||
*/
|
||||
@Composable
|
||||
internal fun <T> ViewPreviewFrame(
|
||||
selected: T,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable (T) -> Unit,
|
||||
) {
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = GroupedListInset)
|
||||
.clip(FRAME_SHAPE)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||
.padding(FRAME_INSET),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = selected,
|
||||
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
|
||||
label = label,
|
||||
) { shown ->
|
||||
// Each preview renders the real view at phone size and scales it
|
||||
// down, so its own corners are square — the plate rounds them.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(PREVIEW_SHAPE)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.clipToBounds(),
|
||||
) {
|
||||
content(shown)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val FRAME_INSET = 8.dp
|
||||
|
||||
/** The grouped rows' own outer radius, so the stack shares one silhouette. */
|
||||
private val FRAME_SHAPE = RoundedCornerShape(22.dp)
|
||||
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
|
||||
@@ -1,22 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.day
|
||||
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.daysUntil
|
||||
import kotlinx.datetime.plus
|
||||
|
||||
/** Pages the day pager spans — a little under four centuries, centred on [PAGE_EPOCH]. */
|
||||
internal const val DAY_PAGE_COUNT: Int = 140_000
|
||||
|
||||
private const val EPOCH_PAGE = DAY_PAGE_COUNT / 2
|
||||
|
||||
/** The day at [EPOCH_PAGE]; any date works, it only has to stay fixed. */
|
||||
private val PAGE_EPOCH = LocalDate(2000, 1, 1)
|
||||
|
||||
/** The day shown on pager [page]. */
|
||||
internal fun dayForPage(page: Int): LocalDate =
|
||||
PAGE_EPOCH.plus(page - EPOCH_PAGE, DateTimeUnit.DAY)
|
||||
|
||||
/** The pager page showing [date]. */
|
||||
internal fun dayPageFor(date: LocalDate): Int =
|
||||
(EPOCH_PAGE + PAGE_EPOCH.daysUntil(date)).coerceIn(0, DAY_PAGE_COUNT - 1)
|
||||
@@ -1,19 +1,13 @@
|
||||
package de.jeanlucmakiola.calendula.ui.detail
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -22,7 +16,6 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -41,7 +34,6 @@ import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Repeat
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -51,7 +43,6 @@ import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -65,19 +56,21 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.BaselineShift
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
@@ -90,24 +83,30 @@ import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.domain.InlineStyle
|
||||
import de.jeanlucmakiola.calendula.domain.RichText
|
||||
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
|
||||
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
||||
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||
import de.jeanlucmakiola.calendula.domain.zoneOrNull
|
||||
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
|
||||
import de.jeanlucmakiola.floret.components.IconInfoCard
|
||||
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.rememberWriteGate
|
||||
import de.jeanlucmakiola.calendula.ui.common.sendEventShare
|
||||
import de.jeanlucmakiola.calendula.ui.common.FieldCopier
|
||||
import de.jeanlucmakiola.calendula.ui.common.copyOnLongPress
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberFieldCopier
|
||||
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.floret.components.OptionCard
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.withRepeatedHourZone
|
||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -162,43 +161,15 @@ fun EventDetailScreen(
|
||||
val onShareClick = {
|
||||
scope.launch {
|
||||
val uri = viewModel.shareUri()
|
||||
val sent = uri != null && runCatching {
|
||||
val send = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/calendar"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(send, shareChooserTitle))
|
||||
}.isSuccess
|
||||
val sent = uri != null && context.sendEventShare(uri, shareChooserTitle)
|
||||
if (!sent) snackbarHostState.showSnackbar(shareFailedMessage)
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
||||
// upgrade in place. Granting continues straight into the tapped action —
|
||||
// edit, delete, or duplicate — held here until the result comes back.
|
||||
var pendingWrite by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||
val writePermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (granted) pendingWrite?.invoke()
|
||||
pendingWrite = null
|
||||
}
|
||||
val hasWritePermission = {
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.WRITE_CALENDAR,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
val requireWrite: (() -> Unit) -> Unit = { action ->
|
||||
if (hasWritePermission()) {
|
||||
action()
|
||||
} else {
|
||||
pendingWrite = action
|
||||
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||
}
|
||||
}
|
||||
// upgrade in place and continues straight into the tapped action.
|
||||
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
|
||||
@@ -322,70 +293,6 @@ fun EventDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete confirmation. Recurring events choose between cancelling just the
|
||||
* tapped occurrence (default), truncating the series from it onwards, and
|
||||
* removing the whole series.
|
||||
*/
|
||||
@Composable
|
||||
private fun DeleteEventDialog(
|
||||
isRecurring: Boolean,
|
||||
onConfirm: (RecurringWriteScope) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var scope by rememberSaveable { mutableStateOf(RecurringWriteScope.ThisEvent) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
stringResource(
|
||||
if (isRecurring) R.string.event_delete_recurring_title
|
||||
else R.string.event_delete_title,
|
||||
),
|
||||
)
|
||||
},
|
||||
text = {
|
||||
if (isRecurring) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_delete_option_occurrence),
|
||||
onClick = { scope = RecurringWriteScope.ThisEvent },
|
||||
selected = scope == RecurringWriteScope.ThisEvent,
|
||||
)
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_delete_option_following),
|
||||
onClick = { scope = RecurringWriteScope.ThisAndFollowing },
|
||||
selected = scope == RecurringWriteScope.ThisAndFollowing,
|
||||
)
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_delete_option_series),
|
||||
onClick = { scope = RecurringWriteScope.AllEvents },
|
||||
selected = scope == RecurringWriteScope.AllEvents,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(stringResource(R.string.event_delete_body))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onConfirm(if (isRecurring) scope else RecurringWriteScope.AllEvents) },
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.event_detail_delete),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.dialog_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun EventDetailContent(
|
||||
state: EventDetailUiState.Success,
|
||||
@@ -394,8 +301,8 @@ private fun EventDetailContent(
|
||||
) {
|
||||
val detail = state.detail
|
||||
val instance = detail.instance
|
||||
val dark = isSystemInDarkTheme()
|
||||
val locale = currentDetailLocale()
|
||||
val dark = LocalFloretDarkTheme.current
|
||||
val locale = currentLocale()
|
||||
val accent = eventAccent(instance.color, dark, LocalSoftenColors.current)
|
||||
|
||||
Column(
|
||||
@@ -409,15 +316,26 @@ private fun EventDetailContent(
|
||||
// cancelled event strikes through its title.
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
val titleLabel = stringResource(R.string.event_detail_title)
|
||||
val titleDecoration = if (detail.status == EventStatus.Cancelled) {
|
||||
TextDecoration.LineThrough
|
||||
} else {
|
||||
null
|
||||
}
|
||||
Text(
|
||||
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
|
||||
// A phone number in the title ("Call Anna 0151 …") dials too.
|
||||
text = if (instance.title.isBlank()) {
|
||||
AnnotatedString(stringResource(R.string.event_untitled))
|
||||
} else {
|
||||
linkify(
|
||||
instance.title,
|
||||
state.phoneLinks.title,
|
||||
MaterialTheme.colorScheme.primary,
|
||||
titleDecoration,
|
||||
)
|
||||
},
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textDecoration = if (detail.status == EventStatus.Cancelled) {
|
||||
TextDecoration.LineThrough
|
||||
} else {
|
||||
null
|
||||
},
|
||||
textDecoration = titleDecoration,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.then(
|
||||
@@ -464,8 +382,8 @@ private fun EventDetailContent(
|
||||
val gap = 12.dp
|
||||
|
||||
// "When" — date/all-day plus the time range.
|
||||
val (whenPrimary, whenSecondary) = formatWhen(instance, TimeZone.currentSystemDefault(), locale)
|
||||
DetailCard(icon = Icons.Default.Schedule, iconContentDescription = null) {
|
||||
val (whenPrimary, whenSecondary) = rememberWhen(instance, TimeZone.currentSystemDefault(), locale)
|
||||
IconInfoCard(icon = Icons.Default.Schedule, iconContentDescription = null) {
|
||||
Text(text = whenPrimary, style = MaterialTheme.typography.titleMedium)
|
||||
if (whenSecondary != null) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
@@ -490,7 +408,7 @@ private fun EventDetailContent(
|
||||
}
|
||||
foreignZone?.let { zoneOption ->
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
IconInfoCard(
|
||||
icon = Icons.Default.Public,
|
||||
iconContentDescription = stringResource(R.string.event_detail_timezone),
|
||||
) {
|
||||
@@ -503,8 +421,8 @@ private fun EventDetailContent(
|
||||
// — which a cross-zone event easily does — carries the whole span
|
||||
// in the primary instead, so fall back to it. If the zone can't be
|
||||
// read back at all, drop to just the abbreviation + offset.
|
||||
val originalTime = runCatching { TimeZone.of(zoneOption.id) }.getOrNull()
|
||||
?.let { formatWhen(instance, it, locale) }
|
||||
val originalTime = zoneOrNull(zoneOption.id)
|
||||
?.let { rememberWhen(instance, it, locale) }
|
||||
?.let { (primary, secondary) -> secondary ?: primary }
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
@@ -522,29 +440,30 @@ private fun EventDetailContent(
|
||||
// Calendar — icon tinted in the calendar colour conveys identity, so no
|
||||
// separate colour dot is needed.
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
IconInfoCard(
|
||||
icon = Icons.Default.CalendarMonth,
|
||||
iconTint = accent,
|
||||
iconContentDescription = stringResource(R.string.event_detail_calendar),
|
||||
) {
|
||||
Text(
|
||||
text = state.calendarName?.ifBlank { stringResource(R.string.calendar_unnamed) }
|
||||
text = state.calendarName?.orUnnamed()
|
||||
?: stringResource(R.string.event_detail_calendar_unknown),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
|
||||
// Location (conditional). URLs open in the browser, the rest in maps.
|
||||
// Location (conditional). URLs open in the browser, phone numbers in the
|
||||
// dialer, the rest in maps.
|
||||
instance.location?.takeIf { it.isNotBlank() }?.let { location ->
|
||||
val context = LocalContext.current
|
||||
val locationLabel = stringResource(R.string.event_detail_location)
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
IconInfoCard(
|
||||
icon = Icons.Default.Place,
|
||||
iconContentDescription = locationLabel,
|
||||
) {
|
||||
Text(
|
||||
text = linkifyUrls(location, MaterialTheme.colorScheme.primary),
|
||||
text = linkify(location, state.phoneLinks.location, MaterialTheme.colorScheme.primary),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
@@ -561,20 +480,21 @@ private fun EventDetailContent(
|
||||
}
|
||||
}
|
||||
|
||||
// Description (conditional). URLs are auto-linked.
|
||||
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
|
||||
// Description (conditional). HTML is rendered (#271); URLs and phone
|
||||
// numbers are auto-linked.
|
||||
state.description?.let { description ->
|
||||
val descriptionLabel = stringResource(R.string.event_detail_description)
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
IconInfoCard(
|
||||
icon = Icons.AutoMirrored.Filled.Notes,
|
||||
iconContentDescription = descriptionLabel,
|
||||
// The gesture sits on the card so the icon and padding answer
|
||||
// it too: every linkified URL owns the pointer over its own
|
||||
// glyphs, which leaves the text itself a patchy target.
|
||||
modifier = Modifier.copyOnLongPress(descriptionLabel, description, copyField),
|
||||
modifier = Modifier.copyOnLongPress(descriptionLabel, description.copyText(), copyField),
|
||||
) {
|
||||
Text(
|
||||
text = linkifyUrls(description, MaterialTheme.colorScheme.primary),
|
||||
text = linkify(description, state.phoneLinks.description, MaterialTheme.colorScheme.primary),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
@@ -584,7 +504,7 @@ private fun EventDetailContent(
|
||||
// each attendee with their role and reply.
|
||||
detail.attendees.takeIf { it.isNotEmpty() }?.let { attendees ->
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
IconInfoCard(
|
||||
icon = Icons.Default.People,
|
||||
iconContentDescription = stringResource(R.string.event_detail_attendees),
|
||||
) {
|
||||
@@ -606,7 +526,7 @@ private fun EventDetailContent(
|
||||
// Reminders (conditional) — list each lead time before the event.
|
||||
detail.reminders.takeIf { it.isNotEmpty() }?.let { reminders ->
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
IconInfoCard(
|
||||
icon = Icons.Default.Notifications,
|
||||
iconContentDescription = stringResource(R.string.event_detail_reminders),
|
||||
) {
|
||||
@@ -615,7 +535,7 @@ private fun EventDetailContent(
|
||||
.sortedBy { it.minutes }
|
||||
.forEach { reminder ->
|
||||
Text(
|
||||
text = reminderLeadText(reminder),
|
||||
text = reminderLeadTimeLabel(reminder.minutes),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
@@ -626,7 +546,7 @@ private fun EventDetailContent(
|
||||
// Recurrence (conditional) — humanised from the RRULE.
|
||||
detail.rrule?.takeIf { it.isNotBlank() }?.let { rrule ->
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
IconInfoCard(
|
||||
icon = Icons.Default.Repeat,
|
||||
iconContentDescription = stringResource(R.string.event_detail_recurrence),
|
||||
) {
|
||||
@@ -639,36 +559,6 @@ private fun EventDetailContent(
|
||||
}
|
||||
}
|
||||
|
||||
/** One info card: tonal container, leading icon in the gutter, value to the right. */
|
||||
@Composable
|
||||
private fun DetailCard(
|
||||
icon: ImageVector,
|
||||
iconContentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = iconContentDescription,
|
||||
tint = iconTint,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f), content = content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttendeeRow(attendee: Attendee) {
|
||||
Row(
|
||||
@@ -792,11 +682,6 @@ 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
|
||||
@@ -816,10 +701,6 @@ 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
|
||||
@@ -847,45 +728,114 @@ private fun foreignTimeZone(
|
||||
)
|
||||
}
|
||||
|
||||
/** Wrap http(s) URLs in [text] as links tinted [linkColor] that open in the browser. */
|
||||
/**
|
||||
* Wrap http(s) URLs in [text] as links that open in the browser, and its
|
||||
* [phoneLinks] as links that open the dialer, both tinted [linkColor]. A
|
||||
* link's underline joins [textDecoration] rather than replacing it, so a
|
||||
* cancelled title stays struck through across its links.
|
||||
*/
|
||||
@Composable
|
||||
private fun linkifyUrls(text: String, linkColor: Color): AnnotatedString {
|
||||
private fun linkify(
|
||||
text: String,
|
||||
phoneLinks: List<PhoneLink>,
|
||||
linkColor: Color,
|
||||
textDecoration: TextDecoration? = null,
|
||||
): AnnotatedString = linkify(RichText(text), phoneLinks, linkColor, textDecoration)
|
||||
|
||||
/**
|
||||
* [linkify] for text rendered from HTML: its own formatting and `<a href>`
|
||||
* links come along, and a URL or number already inside one isn't linked again.
|
||||
*/
|
||||
@Composable
|
||||
private fun linkify(
|
||||
text: RichText,
|
||||
phoneLinks: List<PhoneLink>,
|
||||
linkColor: Color,
|
||||
textDecoration: TextDecoration? = null,
|
||||
): AnnotatedString {
|
||||
val context = LocalContext.current
|
||||
return remember(text, linkColor, context) {
|
||||
val styles = TextLinkStyles(
|
||||
style = SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline),
|
||||
)
|
||||
return remember(text, phoneLinks, linkColor, textDecoration, context) {
|
||||
// Decorations go on separately so overlapping ones add up: a span's
|
||||
// textDecoration replaces whatever sits under it.
|
||||
val styles = TextLinkStyles(style = SpanStyle(color = linkColor))
|
||||
val decorations = mutableListOf<Triple<Int, Int, TextDecoration>>()
|
||||
textDecoration?.let { decorations += Triple(0, text.text.length, it) }
|
||||
for (range in text.styles) {
|
||||
when (range.style) {
|
||||
InlineStyle.Underline -> decorations += Triple(range.start, range.end, TextDecoration.Underline)
|
||||
InlineStyle.Strikethrough -> decorations += Triple(range.start, range.end, TextDecoration.LineThrough)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
fun linked(start: Int, end: Int) = text.links.any { start < it.end && it.start < end }
|
||||
buildAnnotatedString {
|
||||
append(text)
|
||||
for (link in findWebLinks(text)) {
|
||||
addLink(
|
||||
LinkAnnotation.Url(link.url, styles) { openWebLink(context, link.url) },
|
||||
link.start,
|
||||
link.end,
|
||||
)
|
||||
append(text.text)
|
||||
for (range in text.styles) range.style.toSpanStyle()?.let { addStyle(it, range.start, range.end) }
|
||||
fun link(url: String, start: Int, end: Int, onClick: () -> Unit) {
|
||||
addLink(LinkAnnotation.Url(url, styles) { onClick() }, start, end)
|
||||
decorations += Triple(start, end, TextDecoration.Underline)
|
||||
}
|
||||
for (link in text.links) {
|
||||
if (link.url.startsWith("tel:")) {
|
||||
link(link.url, link.start, link.end) { dial(context, Uri.decode(link.url.substring(4))) }
|
||||
} else {
|
||||
link(link.url, link.start, link.end) { openWebLink(context, link.url) }
|
||||
}
|
||||
}
|
||||
for (link in findWebLinks(text.text)) {
|
||||
if (linked(link.start, link.end)) continue
|
||||
link(link.url, link.start, link.end) { openWebLink(context, link.url) }
|
||||
}
|
||||
for (link in phoneLinks) {
|
||||
if (linked(link.start, link.end)) continue
|
||||
link("tel:${link.number}", link.start, link.end) { dial(context, link.number) }
|
||||
}
|
||||
val bounds = decorations.flatMap { listOf(it.first, it.second) }.distinct().sorted()
|
||||
for ((start, end) in bounds.zipWithNext()) {
|
||||
val covering = decorations.filter { it.first <= start && end <= it.second }.map { it.third }.distinct()
|
||||
if (covering.isNotEmpty()) addStyle(SpanStyle(textDecoration = TextDecoration.combine(covering)), start, end)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun InlineStyle.toSpanStyle(): SpanStyle? = when (this) {
|
||||
InlineStyle.Bold -> SpanStyle(fontWeight = FontWeight.Bold)
|
||||
InlineStyle.Italic -> SpanStyle(fontStyle = FontStyle.Italic)
|
||||
InlineStyle.Superscript -> SpanStyle(baselineShift = BaselineShift.Superscript, fontSize = 0.75.em)
|
||||
InlineStyle.Subscript -> SpanStyle(baselineShift = BaselineShift.Subscript, fontSize = 0.75.em)
|
||||
InlineStyle.Monospace -> SpanStyle(fontFamily = FontFamily.Monospace)
|
||||
InlineStyle.Underline, InlineStyle.Strikethrough -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an event's time into a primary line (date, or "All day") and an
|
||||
* optional secondary line (time range). Multi-day timed events collapse into a
|
||||
* single primary line spanning both ends.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberWhen(instance: EventInstance, zone: TimeZone, locale: Locale): Pair<String, String?> {
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val allDayLabel = stringResource(R.string.event_detail_all_day)
|
||||
// Remembered: a time in the repeated hour looks up its zone abbreviation (#387).
|
||||
return remember(instance, zone, locale, use24Hour, allDayLabel) {
|
||||
formatWhen(instance, zone, locale, use24Hour, allDayLabel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatWhen(
|
||||
instance: EventInstance,
|
||||
zone: TimeZone,
|
||||
locale: Locale,
|
||||
use24Hour: Boolean,
|
||||
allDayLabel: String,
|
||||
): Pair<String, String?> {
|
||||
val zid = ZoneId.of(zone.id)
|
||||
val dateFull = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale)
|
||||
val dateMedium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
val timeShort = timeOfDayFormatter(LocalUse24HourFormat.current, locale)
|
||||
val timeShort = timeOfDayFormatter(use24Hour, locale)
|
||||
|
||||
val startLdt = instance.start.toJavaLocalDateTime(zid)
|
||||
val allDayLabel = stringResource(R.string.event_detail_all_day)
|
||||
|
||||
if (instance.isAllDay) {
|
||||
// All-day events live at UTC midnights with an exclusive end. Resolve
|
||||
@@ -905,12 +855,13 @@ private fun formatWhen(
|
||||
}
|
||||
|
||||
val endLdt = instance.end.toJavaLocalDateTime(zid)
|
||||
val startTime = withRepeatedHourZone(timeShort.format(startLdt), instance.start, zone, locale)
|
||||
val endTime = withRepeatedHourZone(timeShort.format(endLdt), instance.end, zone, locale)
|
||||
return if (startLdt.toLocalDate() == endLdt.toLocalDate()) {
|
||||
dateFull.format(startLdt.toLocalDate()) to
|
||||
"${timeShort.format(startLdt)} – ${timeShort.format(endLdt)}"
|
||||
dateFull.format(startLdt.toLocalDate()) to "$startTime – $endTime"
|
||||
} else {
|
||||
val start = "${dateMedium.format(startLdt.toLocalDate())} ${timeShort.format(startLdt)}"
|
||||
val end = "${dateMedium.format(endLdt.toLocalDate())} ${timeShort.format(endLdt)}"
|
||||
val start = "${dateMedium.format(startLdt.toLocalDate())} $startTime"
|
||||
val end = "${dateMedium.format(endLdt.toLocalDate())} $endTime"
|
||||
"$start – $end" to null
|
||||
}
|
||||
}
|
||||
@@ -920,7 +871,15 @@ private fun Instant.toJavaLocalDateTime(zid: ZoneId): java.time.LocalDateTime =
|
||||
|
||||
/** Open [url] in a browsable app, or say so when none can take it. */
|
||||
private fun openWebLink(context: Context, url: String) {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).addCategory(Intent.CATEGORY_BROWSABLE)
|
||||
startOrSayNoApp(context, Intent(Intent.ACTION_VIEW, Uri.parse(url)).addCategory(Intent.CATEGORY_BROWSABLE))
|
||||
}
|
||||
|
||||
/** Open the dialer with [number] filled in, or say so when none can take it. */
|
||||
private fun dial(context: Context, number: String) {
|
||||
startOrSayNoApp(context, Intent(Intent.ACTION_DIAL, Uri.fromParts("tel", number, null)))
|
||||
}
|
||||
|
||||
private fun startOrSayNoApp(context: Context, intent: Intent) {
|
||||
if (runCatching { context.startActivity(intent) }.isFailure) {
|
||||
Toast.makeText(context, R.string.link_open_failed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.detail
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.RichText
|
||||
|
||||
/**
|
||||
* UI state for the event-detail screen (spec S4).
|
||||
@@ -21,6 +22,10 @@ sealed interface EventDetailUiState {
|
||||
* the sync would just resurrect it. Contact dates are removed at the source.
|
||||
*/
|
||||
val isManaged: Boolean = false,
|
||||
/** Phone numbers to link in the title, location and description. */
|
||||
val phoneLinks: EventPhoneLinks = EventPhoneLinks(),
|
||||
/** The description as drawn — HTML rendered (#271); null when there is none. */
|
||||
val description: RichText? = null,
|
||||
) : EventDetailUiState
|
||||
}
|
||||
|
||||
|
||||
@@ -6,20 +6,22 @@ import androidx.lifecycle.viewModelScope
|
||||
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.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.toEditForm
|
||||
import de.jeanlucmakiola.calendula.domain.atOccurrence
|
||||
import de.jeanlucmakiola.calendula.domain.descriptionRichText
|
||||
import de.jeanlucmakiola.calendula.domain.toDuplicateForm
|
||||
import de.jeanlucmakiola.calendula.ui.common.DeleteOutcome
|
||||
import de.jeanlucmakiola.calendula.ui.common.tryDeleteEvent
|
||||
import de.jeanlucmakiola.calendula.ui.common.stageEventShare
|
||||
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.time.Clock
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
@@ -28,11 +30,9 @@ import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
@@ -75,9 +75,8 @@ class EventDetailViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = EventDetailUiState.Loading,
|
||||
)
|
||||
|
||||
@@ -112,22 +111,11 @@ class EventDetailViewModel @Inject constructor(
|
||||
if (_deleteState.value == DeleteUiState.Deleting) return
|
||||
viewModelScope.launch {
|
||||
_deleteState.value = DeleteUiState.Deleting
|
||||
_deleteState.value = try {
|
||||
when (scope) {
|
||||
RecurringWriteScope.AllEvents ->
|
||||
repository.deleteEvent(target.eventId)
|
||||
RecurringWriteScope.ThisEvent ->
|
||||
repository.deleteOccurrence(target.eventId, target.beginMillis)
|
||||
RecurringWriteScope.ThisAndFollowing ->
|
||||
repository.deleteEventFromOccurrence(target.eventId, target.beginMillis)
|
||||
}
|
||||
DeleteUiState.Deleted
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: SecurityException) {
|
||||
DeleteUiState.NeedsPermission
|
||||
} catch (e: Exception) {
|
||||
DeleteUiState.Failed
|
||||
_deleteState.value = when (repository.tryDeleteEvent(target.eventId, target.beginMillis, scope)) {
|
||||
// Already gone is what a delete wants anyway.
|
||||
DeleteOutcome.Deleted, DeleteOutcome.Gone -> DeleteUiState.Deleted
|
||||
DeleteOutcome.NeedsPermission -> DeleteUiState.NeedsPermission
|
||||
DeleteOutcome.Failed -> DeleteUiState.Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,66 +132,46 @@ class EventDetailViewModel @Inject constructor(
|
||||
*/
|
||||
suspend fun shareUri(): Uri? {
|
||||
val detail = (state.value as? EventDetailUiState.Success)?.detail ?: return null
|
||||
return runCatching {
|
||||
withContext(io) {
|
||||
val ics = IcsWriter().writeCalendar(
|
||||
events = listOf(detail.toShareIcsEvent()),
|
||||
dtStamp = Clock.System.now(),
|
||||
)
|
||||
icsExporter.stageShareFile(shareFileName(detail.instance.title), ics)
|
||||
}
|
||||
}.getOrNull()
|
||||
return withContext(io) { icsExporter.stageEventShare(detail) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a create form seeded from the open event so the user can save a
|
||||
* copy as a new, independent event (#52). Returns null when nothing is
|
||||
* loaded.
|
||||
*
|
||||
* The recurrence is dropped — a duplicate is a single event; anyone who
|
||||
* wants a series can re-add one in the edit form that opens. The source
|
||||
* calendar is kept only when it's writable, otherwise the id is cleared so
|
||||
* the create resolves to the last-used/first-writable calendar — which lets
|
||||
* a read-only event (a WebCal subscription, a birthday) be copied into an
|
||||
* editable calendar. The occurrence's own times carry over unchanged.
|
||||
* A create form seeded from the open event, to save a copy as a new event
|
||||
* (#52) — see [toDuplicateForm]. Null when nothing is loaded.
|
||||
*/
|
||||
fun duplicateForm(): EventForm? {
|
||||
val loaded = state.value as? EventDetailUiState.Success ?: return null
|
||||
val detail = loaded.detail
|
||||
return detail.toEditForm(
|
||||
beginMillis = detail.instance.start.toEpochMilliseconds(),
|
||||
endMillis = detail.instance.end.toEpochMilliseconds(),
|
||||
zone = TimeZone.currentSystemDefault(),
|
||||
).copy(
|
||||
rrule = null,
|
||||
calendarId = detail.instance.calendarId.takeIf { loaded.canModify },
|
||||
)
|
||||
return loaded.detail.toDuplicateForm(loaded.canModify, TimeZone.currentSystemDefault())
|
||||
}
|
||||
|
||||
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
||||
val detail = repository.eventDetail(target.eventId)
|
||||
// The Events row holds the series start; replace it with this
|
||||
// occurrence's time so recurring events render correctly. An external
|
||||
// "open event" that names no occurrence ([NO_OCCURRENCE_TIME] — e.g. a
|
||||
// bare content://.../events/<id> VIEW intent, issue #48) keeps the row's
|
||||
// own DTSTART/DTEND instead of overriding it to the epoch.
|
||||
// An external "open event" that names no occurrence ([NO_OCCURRENCE_TIME] —
|
||||
// e.g. a bare content://.../events/<id> VIEW intent, issue #48) keeps the
|
||||
// row's own DTSTART/DTEND instead of overriding it to the epoch.
|
||||
val corrected = if (target.beginMillis == NO_OCCURRENCE_TIME) {
|
||||
detail
|
||||
} else {
|
||||
detail.copy(
|
||||
instance = detail.instance.copy(
|
||||
start = Instant.fromEpochMilliseconds(target.beginMillis),
|
||||
end = Instant.fromEpochMilliseconds(target.endMillis),
|
||||
),
|
||||
)
|
||||
detail.atOccurrence(target.beginMillis, target.endMillis)
|
||||
}
|
||||
val calendar = repository.calendars().first()
|
||||
.firstOrNull { it.id == corrected.instance.calendarId }
|
||||
val description = corrected.description
|
||||
?.let(::descriptionRichText)
|
||||
?.takeIf { it.text.isNotBlank() }
|
||||
EventDetailUiState.Success(
|
||||
detail = corrected,
|
||||
calendarName = calendar?.displayName,
|
||||
canModify = calendar?.canModifyContents == true,
|
||||
isManaged = calendar?.isManaged == true,
|
||||
// Found here, off the main thread and once per load: the platform
|
||||
// matcher loads its metadata on first use.
|
||||
phoneLinks = findEventPhoneLinks(
|
||||
title = corrected.instance.title,
|
||||
location = corrected.instance.location,
|
||||
description = description?.text,
|
||||
),
|
||||
description = description,
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
@@ -228,13 +196,3 @@ class EventDetailViewModel @Inject constructor(
|
||||
const val NO_OCCURRENCE_TIME: Long = Long.MIN_VALUE
|
||||
}
|
||||
}
|
||||
|
||||
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */
|
||||
private fun shareFileName(title: String): String {
|
||||
val base = title.trim()
|
||||
.replace(Regex("""[^\p{L}\p{Nd} _-]"""), "")
|
||||
.replace(' ', '_')
|
||||
.take(40)
|
||||
.ifBlank { "event" }
|
||||
return "$base.ics"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package de.jeanlucmakiola.calendula.ui.detail
|
||||
|
||||
import android.text.SpannableString
|
||||
import android.text.style.URLSpan
|
||||
import android.text.util.Linkify
|
||||
|
||||
/**
|
||||
* A phone number found in free text, spanning [start] until [end] (exclusive).
|
||||
* [number] is what the dialer gets: digits, a leading `+`, and any `,`/`#`
|
||||
* dial-in suffix — the separators that only made it readable are gone.
|
||||
*/
|
||||
data class PhoneLink(val start: Int, val end: Int, val number: String)
|
||||
|
||||
/** The phone numbers in an event's free-text fields, each against its own text. */
|
||||
data class EventPhoneLinks(
|
||||
val title: List<PhoneLink> = emptyList(),
|
||||
val location: List<PhoneLink> = emptyList(),
|
||||
val description: List<PhoneLink> = emptyList(),
|
||||
)
|
||||
|
||||
/** Where the platform matcher saw a phone number: [start] until [end] (exclusive). */
|
||||
internal data class PhoneMatch(val start: Int, val end: Int)
|
||||
|
||||
// A conference dial-in's pauses and meeting id right after the number:
|
||||
// "+16465588656,,85012345678#" or Zoom's ",,850…#,,,,*123456#". It opens on a
|
||||
// double comma or closes on "#", so "030 1234567,0151 2345678" stays a list.
|
||||
private val dialSuffixRegex = Regex("""^(?:,,+[\d*]+#?|,[\d*]+#)(?:,+[\d*]+#?)*""")
|
||||
|
||||
// Whitespace as invites write it: Java's \s misses the no-break space and the
|
||||
// invisible direction marks Meet and Outlook wrap around numbers.
|
||||
private const val GAP = """\s\u00A0\u200E\u200F\u202A-\u202E\u2066-\u2069"""
|
||||
|
||||
// A number right after one of these labels is an id to type in, not one to
|
||||
// call. English and German cover the Zoom/Teams/Meet/Webex invites seen so far.
|
||||
// "Conference number" is left out: invites use it for the dial-in itself.
|
||||
private val notAPhoneLabelRegex = Regex(
|
||||
"""(?:(?:meeting|webinar|besprechungs)[ -]?(?:id|number|nummer)|(?:conference|konferenz)[ -]?id|""" +
|
||||
"""\bpin|passcode|password|passwort|kenncode|access code|zugangscode)[$GAP:#.()]*$""",
|
||||
RegexOption.IGNORE_CASE,
|
||||
)
|
||||
|
||||
// A day.month.year (or year-month-day, or US month/day/year) date: exactly
|
||||
// three groups, so a French "01.23.45.67.89" chain doesn't read as one.
|
||||
private val dateRegex = Regex("""(?<![\d./-])(\d{1,4})([./-])(\d{1,2})\2(\d{4}|\d{2})(?![\d]|[./-]\d)""")
|
||||
|
||||
// Thousands groups. A short lead ("1.250.000", "12 500 000") is an amount; a
|
||||
// three-digit lead is one only beside a currency — "612 345 678" and
|
||||
// "612.345.678" are Spanish mobiles, "125.000.000 €" a budget.
|
||||
private val shortLeadAmountRegex = Regex("""[1-9]\d?(?:([. ])\d{3})(?:\1\d{3})*""")
|
||||
private val amountRegex = Regex("""[1-9]\d{0,2}(?:([. ])\d{3})(?:\1\d{3})*""")
|
||||
private val currencyBeforeRegex = Regex("""(?:[€$£]|\b(?:eur|euro|usd|chf|gbp))\s*$""", RegexOption.IGNORE_CASE)
|
||||
private val currencyAfterRegex = Regex("""^\s*(?:[€$£]|(?:eur|euro|usd|chf|gbp)\b)""", RegexOption.IGNORE_CASE)
|
||||
|
||||
// A short group set off by a space after a long one, with a word straight
|
||||
// after — "… 2345678 2 times" — is the prose's count, not the number's tail.
|
||||
// Without the word ("030 123456 0") or after a short group ("0221 1234 56")
|
||||
// it is the number's own.
|
||||
private val strayTailRegex = Regex("""(?<=\d{6})\s+\d{1,2}$""")
|
||||
private val wordAfterRegex = Regex("""[ \t\u00A0]+\p{L}""")
|
||||
|
||||
// "9.30 – 12.00", "10.00-11.30": times of day written the German way.
|
||||
private val dottedTimesRegex = Regex("""(\d{1,2})\.(\d{2})(?:\s*[-–]\s*(\d{1,2})\.(\d{2}))?""")
|
||||
|
||||
// "ext. 567", "x567", "Durchwahl 567": the dialer reaches an extension after a pause.
|
||||
private val extensionRegex = Regex("""\s*(?:ext\.?|extension|x|durchwahl|dw\.?)\s*(?=\d)""", RegexOption.IGNORE_CASE)
|
||||
|
||||
private val trunkPrefixRegex = Regex("""\(\s*0\s*\)""")
|
||||
private val strandedCountryCodeRegex = Regex("""\+\d{1,3}\s*\(\s*0\s*\)\s*$""")
|
||||
|
||||
// Short local numbers exist, but in event text a 5- or 6-digit run is far more
|
||||
// often a postcode, house number or booking reference.
|
||||
private const val MIN_DIGITS = 7
|
||||
|
||||
/**
|
||||
* Find the phone numbers in [text]. The platform's libphonenumber-backed
|
||||
* matcher does the finding — it knows the device's region and real number
|
||||
* lengths; [refinePhoneMatches] then fits what it found to an event's text.
|
||||
* Call off the main thread: the matcher loads its metadata on first use.
|
||||
* No match at all when the platform matcher fails.
|
||||
*/
|
||||
internal fun findPhoneLinks(text: String): List<PhoneLink> =
|
||||
refinePhoneMatches(text, platformPhoneMatches(text), findWebLinks(text))
|
||||
|
||||
/** [findPhoneLinks] for each of an event's free-text fields. */
|
||||
internal fun findEventPhoneLinks(title: String, location: String?, description: String?) = EventPhoneLinks(
|
||||
title = findPhoneLinks(title),
|
||||
location = location?.let(::findPhoneLinks).orEmpty(),
|
||||
description = description?.let(::findPhoneLinks).orEmpty(),
|
||||
)
|
||||
|
||||
// The matcher reads the region off the SIM, which throws on a device without
|
||||
// telephony; a field then just goes without phone links.
|
||||
private fun platformPhoneMatches(text: String): List<PhoneMatch> = runCatching {
|
||||
val spannable = SpannableString(text)
|
||||
if (!Linkify.addLinks(spannable, Linkify.PHONE_NUMBERS)) return emptyList()
|
||||
spannable.getSpans(0, spannable.length, URLSpan::class.java)
|
||||
.map { PhoneMatch(spannable.getSpanStart(it), spannable.getSpanEnd(it)) }
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
/**
|
||||
* Turn the platform's [matches] in [text] into dialable links. The matcher is
|
||||
* lenient, so a match is dropped when it sits inside one of the [webLinks],
|
||||
* follows a meeting id or PIN label, holds a date, reads as an amount or is
|
||||
* too short. What's kept loses a stray count off its end, regains an
|
||||
* international "+49 (0)" prefix the matcher left behind, and carries a
|
||||
* conference dial-in suffix along.
|
||||
*/
|
||||
internal fun refinePhoneMatches(
|
||||
text: String,
|
||||
matches: List<PhoneMatch>,
|
||||
webLinks: List<WebLink>,
|
||||
): List<PhoneLink> {
|
||||
val links = mutableListOf<PhoneLink>()
|
||||
for (match in matches.sortedBy { it.start }) {
|
||||
var start = match.start
|
||||
var end = match.end
|
||||
val matched = text.substring(start, end)
|
||||
if (webLinks.any { start < it.end && it.start < end }) continue
|
||||
if (gluedIntoAWord(text, start, end)) continue
|
||||
val lineBefore = text.window(lineStart(text, start).coerceAtLeast(start - LOOK_BEHIND), start)
|
||||
if (notAPhoneLabelRegex.containsMatchIn(lineBefore)) continue
|
||||
if (dateRegex.findAll(matched).any { it.isDate() }) continue
|
||||
if (isDottedTimes(matched)) continue
|
||||
if (isAmount(text, start, end)) continue
|
||||
strayTailRegex.find(matched)?.let { tail ->
|
||||
if (wordAfterRegex.matchesAt(text, end)) end = start + tail.range.first
|
||||
}
|
||||
// "+49 (0)151 …": a matcher that stops at the "(0)" leaves the domestic
|
||||
// tail, which would dial without the country code.
|
||||
strandedCountryCodeRegex.find(text.window(start - LOOK_BEHIND, start))
|
||||
?.let { start -= it.value.length }
|
||||
val number = dialable(text.substring(start, end))
|
||||
if (number.count { it.isDigit() } < MIN_DIGITS) continue
|
||||
val suffix = dialSuffixRegex.find(text.window(end, end + LOOK_AHEAD))?.value.orEmpty()
|
||||
// A suffix swallowed what the matcher saw as the next number.
|
||||
if (links.lastOrNull()?.let { start < it.end } == true) continue
|
||||
links += PhoneLink(start, end + suffix.length, number + dialable(suffix))
|
||||
}
|
||||
return links
|
||||
}
|
||||
|
||||
// How far around a match the label, prefix, currency and suffix checks look.
|
||||
// Enough for any of them, and it keeps a long invite from being re-read whole
|
||||
// for every number in it.
|
||||
private const val LOOK_BEHIND = 40
|
||||
private const val LOOK_AHEAD = 60
|
||||
|
||||
private fun String.window(from: Int, to: Int): String =
|
||||
substring(from.coerceAtLeast(0), to.coerceAtMost(length))
|
||||
|
||||
/**
|
||||
* Digits run straight into a letter or a URL path — "zoom.us/j/850…" without
|
||||
* its https:// — which no web link covered. A phone number stands apart.
|
||||
*/
|
||||
private fun gluedIntoAWord(text: String, start: Int, end: Int): Boolean {
|
||||
val before = text.getOrNull(start - 1)
|
||||
val after = text.getOrNull(end)
|
||||
return (before != null && (before.isLetter() || before in "/=?&")) ||
|
||||
(after != null && (after.isLetter() || after == '/'))
|
||||
}
|
||||
|
||||
private fun isDottedTimes(matched: String): Boolean {
|
||||
val times = dottedTimesRegex.matchEntire(matched) ?: return false
|
||||
return times.groupValues.drop(1).chunked(2).all { (hour, minute) ->
|
||||
hour.isEmpty() || (hour.toInt() in 0..24 && minute.toInt() in 0..59)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isAmount(text: String, start: Int, end: Int): Boolean {
|
||||
val matched = text.substring(start, end)
|
||||
if (shortLeadAmountRegex.matches(matched)) return true
|
||||
return amountRegex.matches(matched) &&
|
||||
(currencyBeforeRegex.containsMatchIn(text.window(start - LOOK_BEHIND, start)) ||
|
||||
currencyAfterRegex.containsMatchIn(text.window(end, end + LOOK_AHEAD)))
|
||||
}
|
||||
|
||||
private fun MatchResult.isDate(): Boolean {
|
||||
val (a, _, b, c) = destructured
|
||||
val first = a.toInt()
|
||||
val second = b.toInt()
|
||||
val last = c.toInt()
|
||||
return when {
|
||||
a.length == 4 -> second in 1..12 && last in 1..31 && c.length <= 2
|
||||
a.length > 2 -> false
|
||||
// Day first (most of the world) or month first (US).
|
||||
else -> (first in 1..31 && second in 1..12) || (first in 1..12 && second in 1..31)
|
||||
}
|
||||
}
|
||||
|
||||
private fun lineStart(text: String, index: Int): Int = text.lastIndexOf('\n', index - 1) + 1
|
||||
|
||||
/**
|
||||
* Strip [raw] down to what a dialer takes. An international number's "(0)"
|
||||
* trunk prefix — "+49 (0) 151 …" — is a domestic-only hint and would break the
|
||||
* call, so it goes too; an extension becomes a pause.
|
||||
*/
|
||||
private fun dialable(raw: String): String {
|
||||
val international = raw.trimStart('(').startsWith("+")
|
||||
val withoutTrunk = if (international) raw.replace(trunkPrefixRegex, "") else raw
|
||||
return withoutTrunk.replace(extensionRegex, ",")
|
||||
.filter { it.isDigit() || it == '+' || it == ',' || it == '#' || it == '*' }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user