Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17b3753ad3 | ||
|
|
efc36f1bff | ||
|
|
f55cfffa3a | ||
|
|
bb8c91acc5 | ||
|
|
b627ed04b7 | ||
|
|
d8e3250af9 | ||
|
|
8bd2ac7a9a | ||
|
|
c1c529d4bd | ||
|
|
aca048e593 | ||
|
|
440f85d89f | ||
|
|
bf2e59a14e | ||
|
|
19f0dcae5d | ||
|
|
c3401ed61c |
@@ -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)!!)
|
||||
}
|
||||
}
|
||||
@@ -29,11 +29,15 @@ import androidx.lifecycle.lifecycleScope
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
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
|
||||
@@ -65,9 +69,6 @@ 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() {
|
||||
|
||||
@@ -79,23 +80,12 @@ class MainActivity : AppCompatActivity() {
|
||||
// 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)
|
||||
|
||||
// A navigation a home-screen widget asked for (open a date / start a
|
||||
// create). Consumed once by CalendarHost, same pattern as the detail key.
|
||||
private var requestedNav by mutableStateOf<WidgetNavRequest?>(null)
|
||||
|
||||
// An .ics file opened/shared into the app (ACTION_VIEW/SEND). Consumed once
|
||||
// by CalendarHost's import flow.
|
||||
private var requestedImportUri by mutableStateOf<Uri?>(null)
|
||||
|
||||
// A prefilled new-event form from an external ACTION_INSERT launch (another
|
||||
// app/widget asking us to create an event, issue #30) or shared text (#306).
|
||||
// Consumed once by CalendarHost, which opens it in the create form for review.
|
||||
private var requestedInsert by mutableStateOf<InsertRequest?>(null)
|
||||
private val requests = ExternalRequests(
|
||||
onExternalFormClosed = {
|
||||
insertFormOpen = false
|
||||
if (launchedForInsert) finish()
|
||||
},
|
||||
)
|
||||
|
||||
// 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.
|
||||
@@ -105,11 +95,6 @@ class MainActivity : AppCompatActivity() {
|
||||
// app that asked instead of leaving the calendar on top of it.
|
||||
private var launchedForInsert = false
|
||||
|
||||
// An external "edit this event" (ACTION_EDIT on content://.../events/<id>):
|
||||
// opens the occurrence in the edit form. Same occurrence-key shape as the
|
||||
// detail channel; consumed once by CalendarHost.
|
||||
private var requestedEditKey by mutableStateOf<LongArray?>(null)
|
||||
|
||||
// A captured crash report awaiting the user's decision, surfaced as a dialog
|
||||
// over the calendar on the next launch (the single-crash path). A startup
|
||||
// crash-loop is handled out of band, before setContent — see below.
|
||||
@@ -137,11 +122,7 @@ class MainActivity : AppCompatActivity() {
|
||||
// backdrop and bar icons instead of the night qualifier's (#181).
|
||||
val cachedTheme = WindowThemeCache.read(this)
|
||||
cachedTheme?.windowBackgroundRes()?.let { window.setBackgroundDrawableResource(it) }
|
||||
systemBarsDark = when (cachedTheme) {
|
||||
null, ThemeMode.SYSTEM -> systemDark
|
||||
ThemeMode.LIGHT -> false
|
||||
ThemeMode.DARK, ThemeMode.BLACK -> true
|
||||
}
|
||||
systemBarsDark = (cachedTheme ?: ThemeMode.SYSTEM).isDark(systemDark)
|
||||
applyEdgeToEdge()
|
||||
lifecycleScope.launch {
|
||||
settingsPrefs.themeMode.collect { mode ->
|
||||
@@ -163,28 +144,24 @@ class MainActivity : AppCompatActivity() {
|
||||
// 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()
|
||||
requests.importUri = intent.importUriOrNull()
|
||||
val insertOpenBefore = savedInstanceState?.getBoolean(KEY_INSERT_FORM_OPEN) == true
|
||||
requestedInsert = intent.createRequestOrNull()?.takeIf { !replaying || insertOpenBefore }
|
||||
insertFormOpen = requestedInsert != null
|
||||
requests.insert = intent.createRequestOrNull()?.takeIf { !replaying || insertOpenBefore }
|
||||
insertFormOpen = requests.insert != null
|
||||
launchedForInsert = savedInstanceState?.getBoolean(KEY_LAUNCHED_FOR_INSERT)
|
||||
?: (requestedInsert != null)
|
||||
?: (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, ThemeMode.BLACK -> 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.
|
||||
@@ -223,7 +200,7 @@ class MainActivity : AppCompatActivity() {
|
||||
CalendulaTheme(
|
||||
darkTheme = darkTheme,
|
||||
dynamicColor = settings.dynamicColor,
|
||||
pureBlack = settings.themeMode == ThemeMode.BLACK,
|
||||
pureBlack = settings.themeMode.isPureBlack,
|
||||
typography = typography,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
@@ -236,21 +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 },
|
||||
onExternalFormClosed = {
|
||||
insertFormOpen = false
|
||||
if (launchedForInsert) finish()
|
||||
},
|
||||
requestedEditKey = requestedEditKey,
|
||||
onEditKeyConsumed = { requestedEditKey = null },
|
||||
requests = requests,
|
||||
)
|
||||
}
|
||||
// A persistent corner marker so a debug build is never
|
||||
@@ -310,14 +273,14 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
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.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requests.detailKey = it }
|
||||
intent.navRequestOrNull()?.let { requests.nav = it }
|
||||
intent.importUriOrNull()?.let { requests.importUri = it }
|
||||
intent.createRequestOrNull()?.let {
|
||||
requestedInsert = it
|
||||
requests.insert = it
|
||||
insertFormOpen = true
|
||||
}
|
||||
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
|
||||
intent.editEventKeyOrNull()?.let { requests.editKey = it }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -372,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
|
||||
@@ -479,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,
|
||||
@@ -631,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() }
|
||||
@@ -901,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) {
|
||||
|
||||
@@ -24,8 +24,6 @@ import de.jeanlucmakiola.calendula.data.time.AndroidTodaySource
|
||||
import de.jeanlucmakiola.calendula.data.time.AndroidZoneSource
|
||||
import de.jeanlucmakiola.calendula.data.time.TodaySource
|
||||
import de.jeanlucmakiola.calendula.data.time.ZoneSource
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.calendulaDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
@@ -87,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)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
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 kotlinx.coroutines.channels.awaitClose
|
||||
import de.jeanlucmakiola.floret.android.broadcastTicks
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.datetime.TimeZone
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -33,17 +28,7 @@ class AndroidZoneSource @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ZoneSource {
|
||||
|
||||
override val zone: Flow<TimeZone> = callbackFlow {
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
trySend(Unit)
|
||||
}
|
||||
}
|
||||
val filter = IntentFilter(Intent.ACTION_TIMEZONE_CHANGED)
|
||||
ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
awaitClose { context.unregisterReceiver(receiver) }
|
||||
}
|
||||
.onStart { emit(Unit) }
|
||||
override val zone: Flow<TimeZone> = context.broadcastTicks(Intent.ACTION_TIMEZONE_CHANGED)
|
||||
.map { current() }
|
||||
.distinctUntilChanged()
|
||||
|
||||
|
||||
@@ -148,23 +148,21 @@ 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 = begin.toLocalDateTime(TimeZone.UTC).date
|
||||
val endExclusive = finish.toLocalDateTime(TimeZone.UTC).date
|
||||
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
|
||||
FormTime(LocalDateTime(startDate, LocalTime(9, 0))) to FormTime(LocalDateTime(endDate, LocalTime(10, 0)))
|
||||
allDayFormTimes(begin.toLocalDateTime(TimeZone.UTC).date, finish.toLocalDateTime(TimeZone.UTC).date)
|
||||
} else {
|
||||
begin.toFormTime(formZone) to finish.toFormTime(formZone)
|
||||
}
|
||||
@@ -180,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.
|
||||
@@ -232,6 +230,19 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
|
||||
rowEnd = instance.end,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
@@ -243,8 +254,7 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
|
||||
*/
|
||||
fun EventForm.isPinnedAwayFrom(target: TimeZone): Boolean {
|
||||
if (isAllDay) return false
|
||||
val pinned = timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: return false
|
||||
return pinned.id != target.id
|
||||
return resolvedZone(target).id != target.id
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -17,12 +17,15 @@ 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
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
@@ -35,17 +32,11 @@ fun buildInsertEventForm(
|
||||
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
|
||||
FormTime(LocalDateTime(startDate, LocalTime(9, 0))) to FormTime(LocalDateTime(endDate, LocalTime(10, 0)))
|
||||
val endExclusive = endMillis?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
|
||||
allDayFormTimes(startDate, endExclusive)
|
||||
} else {
|
||||
val startAt = beginMillis?.let(Instant::fromEpochMilliseconds) ?: nextFullHour(now)
|
||||
val endAt = endMillis?.let(Instant::fromEpochMilliseconds)?.takeIf { it >= startAt }
|
||||
@@ -62,7 +53,7 @@ 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(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(";")
|
||||
}
|
||||
|
||||
@@ -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,11 +1,9 @@
|
||||
package de.jeanlucmakiola.calendula.domain.ics
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.FormTime
|
||||
import de.jeanlucmakiola.calendula.domain.allDayFormTimes
|
||||
import de.jeanlucmakiola.calendula.domain.normalizedRrule
|
||||
import de.jeanlucmakiola.calendula.domain.toFormTime
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
|
||||
@@ -23,10 +21,7 @@ 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))
|
||||
FormTime(LocalDateTime(startDate, LocalTime(9, 0))) to FormTime(LocalDateTime(endDate, LocalTime(10, 0)))
|
||||
allDayFormTimes(this.start.toLocalDateTime(TimeZone.UTC).date, this.end.toLocalDateTime(TimeZone.UTC).date)
|
||||
} else {
|
||||
this.start.toFormTime(zone) to this.end.toFormTime(zone)
|
||||
}
|
||||
@@ -40,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
|
||||
@@ -28,6 +29,7 @@ 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
|
||||
@@ -39,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
|
||||
@@ -99,7 +102,6 @@ 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
|
||||
|
||||
/**
|
||||
@@ -116,29 +118,18 @@ import kotlin.time.Instant
|
||||
* view remains. So back from a widget-opened screen returns to that widget's
|
||||
* view, then home; and back through pill switches walks the views in reverse.
|
||||
*
|
||||
* [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 = {},
|
||||
onExternalFormClosed: () -> Unit = {},
|
||||
requestedEditKey: LongArray? = null,
|
||||
onEditKeyConsumed: () -> Unit = {},
|
||||
viewModel: CalendarHostViewModel = hiltViewModel(),
|
||||
) {
|
||||
// Wait for the persisted default view before seeding the stack, so the app
|
||||
@@ -163,7 +154,7 @@ fun CalendarHost(
|
||||
val view = viewStack.last()
|
||||
// The date every view opens on and carries along (#184); tapping a day
|
||||
// anywhere focuses it and opens the day view there.
|
||||
val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(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
|
||||
@@ -194,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 onOpenDetail: (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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,31 +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) }
|
||||
// A drafted block's start and end (#153) as epoch millis. Instants rather than
|
||||
// minutes into a day, so the form opens at the times drawn even across a DST
|
||||
// change (#387).
|
||||
var createDraftMillis by rememberSaveable { mutableStateOf<LongArray?>(null) }
|
||||
var heldCreateDraftMillis by remember { mutableStateOf<LongArray?>(null) }
|
||||
fun openCreate(date: LocalDate, startMinutes: Int?, draftMillis: LongArray?) {
|
||||
heldCreateIso = date.toString()
|
||||
createDateIso = date.toString()
|
||||
heldCreateMinutes = startMinutes
|
||||
createStartMinutes = startMinutes
|
||||
heldCreateDraftMillis = draftMillis
|
||||
createDraftMillis = draftMillis
|
||||
// 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)
|
||||
}
|
||||
val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes -> openCreate(date, startMinutes, 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, null, longArrayOf(start.toEpochMilliseconds(), end.toEpochMilliseconds()))
|
||||
openCreate(date.toString(), draftMillis = longArrayOf(start.toEpochMilliseconds(), end.toEpochMilliseconds()))
|
||||
}
|
||||
|
||||
// Edit form (v1.3) — reuses the detail screen's occurrence key; for
|
||||
@@ -272,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
|
||||
@@ -306,7 +272,7 @@ fun CalendarHost(
|
||||
importForm = null
|
||||
if (importFromRequest) {
|
||||
importFromRequest = false
|
||||
onExternalFormClosed()
|
||||
requests.onExternalFormClosed()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -325,26 +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) {
|
||||
val requestedInsert = requests.insert
|
||||
LaunchedEffect(requestedInsert) {
|
||||
if (requestedInsert != null) {
|
||||
if (importForm == null) {
|
||||
openImportForm(requestedInsertForm, requestedInsertSource, fromRequest = true)
|
||||
openImportForm(requestedInsert.form, requestedInsert.source, fromRequest = true)
|
||||
} else {
|
||||
pendingInsert = requestedInsertForm to requestedInsertSource
|
||||
pendingInsert = requestedInsert.form to requestedInsert.source
|
||||
}
|
||||
onInsertConsumed()
|
||||
requests.insert = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,21 +335,22 @@ fun CalendarHost(
|
||||
// An external "edit this event" (ACTION_EDIT, e.g. an assistant/task app or
|
||||
// widget) opens the occurrence straight in the edit form. Drop any covering
|
||||
// overlay first — the edit overlay sits below Settings/import in the Box, so
|
||||
// without this it would open hidden underneath them. 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++
|
||||
@@ -392,23 +361,21 @@ fun CalendarHost(
|
||||
// Drill the day view in over the widget's view: drop any overlay
|
||||
// that would cover it, so the open doesn't land under Settings/form.
|
||||
dismissCoveringOverlays()
|
||||
createDateIso = null
|
||||
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
|
||||
@@ -416,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 /
|
||||
@@ -427,15 +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
|
||||
heldCreateDraftMillis = null
|
||||
createDraftMillis = null
|
||||
onWidgetNavConsumed()
|
||||
openCreate(req.dateIso ?: currentDate().toString())
|
||||
requests.nav = null
|
||||
}
|
||||
null -> {}
|
||||
}
|
||||
@@ -448,15 +408,7 @@ fun CalendarHost(
|
||||
val eventActions: EventActionsViewModel = hiltViewModel()
|
||||
val moveOutcome by reschedule.outcome.collectAsStateWithLifecycle()
|
||||
val selectionFailure by eventActions.failure.collectAsStateWithLifecycle()
|
||||
val onEditEvent: (EventInstance) -> Unit = { event ->
|
||||
val key = longArrayOf(
|
||||
event.eventId,
|
||||
event.start.toEpochMilliseconds(),
|
||||
event.end.toEpochMilliseconds(),
|
||||
)
|
||||
heldEditKey = key
|
||||
editKey = key
|
||||
}
|
||||
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.
|
||||
@@ -494,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
|
||||
@@ -696,11 +643,7 @@ fun CalendarHost(
|
||||
|
||||
// Search overlay — below detail/edit in the Box so a tapped result's
|
||||
// detail screen draws on top, and closing it returns to the results.
|
||||
AnimatedVisibility(
|
||||
visible = showSearch,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = overlayExit,
|
||||
) {
|
||||
SlideOverlay(visible = showSearch) {
|
||||
SearchScreen(
|
||||
onBack = { showSearch = false },
|
||||
onEventClick = onEventClick,
|
||||
@@ -708,23 +651,15 @@ 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
|
||||
@@ -738,32 +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,
|
||||
initialDraft = createDraftMillis ?: heldCreateDraftMillis,
|
||||
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,
|
||||
@@ -780,10 +709,7 @@ fun CalendarHost(
|
||||
// below to show why, so open it.
|
||||
onLoadFailed = {
|
||||
editKey = null
|
||||
if (detailKey == null) {
|
||||
heldKey = key
|
||||
detailKey = key
|
||||
}
|
||||
if (detailKey == null) detailKey = key
|
||||
},
|
||||
visible = editKey != null,
|
||||
viewModel = hiltViewModel(key = "edit"),
|
||||
@@ -792,11 +718,7 @@ fun CalendarHost(
|
||||
}
|
||||
|
||||
// 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 },
|
||||
@@ -854,11 +776,7 @@ fun CalendarHost(
|
||||
|
||||
// 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 },
|
||||
@@ -867,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
|
||||
@@ -910,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,11 +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.first
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.LocalDate
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -29,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,
|
||||
)
|
||||
|
||||
@@ -74,9 +68,8 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,27 +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 = {},
|
||||
onExternalFormClosed: () -> 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
|
||||
@@ -74,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.
|
||||
@@ -146,19 +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,
|
||||
onExternalFormClosed = onExternalFormClosed,
|
||||
requestedEditKey = requestedEditKey,
|
||||
onEditKeyConsumed = onEditKeyConsumed,
|
||||
)
|
||||
// Both halves of a transition read their position out of the live
|
||||
// plan; the one the outgoing step was drawn with is already gone.
|
||||
|
||||
@@ -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,5 +1,7 @@
|
||||
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.layout.Box
|
||||
@@ -9,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
|
||||
@@ -30,23 +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
|
||||
@@ -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)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,6 @@ 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
|
||||
@@ -26,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
|
||||
@@ -66,11 +62,9 @@ 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
|
||||
@@ -379,7 +373,6 @@ internal fun AgendaContent(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun AgendaList(
|
||||
days: List<AgendaDay>,
|
||||
@@ -444,39 +437,17 @@ private fun AgendaList(
|
||||
// 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.isPastOn(day.date, now, zone),
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -24,17 +27,13 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
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
|
||||
|
||||
@@ -66,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,
|
||||
)
|
||||
|
||||
@@ -79,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,
|
||||
)
|
||||
|
||||
@@ -101,21 +98,14 @@ class AgendaViewModel @Inject constructor(
|
||||
var lastTopDate: LocalDate? = null
|
||||
private set
|
||||
|
||||
private var lastToday = todayDate
|
||||
|
||||
/**
|
||||
* Today, kept current across midnight (#373). An agenda still starting on
|
||||
* yesterday moves on to the new day; one jumped elsewhere stays put.
|
||||
*/
|
||||
private val today: StateFlow<LocalDate> = todaySource.today
|
||||
.onEach { date ->
|
||||
val old = lastToday
|
||||
lastToday = date
|
||||
if (old != date && _window.value.anchor == old) {
|
||||
_window.value = _window.value.copy(anchor = 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
|
||||
@@ -156,9 +146,8 @@ class AgendaViewModel @Inject constructor(
|
||||
}
|
||||
.catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = AgendaUiState.Loading,
|
||||
)
|
||||
|
||||
@@ -245,8 +234,5 @@ class AgendaViewModel @Inject constructor(
|
||||
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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -12,27 +12,21 @@ 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
|
||||
@@ -40,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
|
||||
@@ -84,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
|
||||
@@ -93,12 +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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -77,6 +77,8 @@ 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. */
|
||||
@@ -126,10 +128,12 @@ fun PublishSelection(
|
||||
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 {
|
||||
@@ -137,6 +141,7 @@ fun PublishSelection(
|
||||
event = it,
|
||||
dismiss = { currentDismiss() },
|
||||
create = if (drafted) ({ currentCreate?.invoke() }) else null,
|
||||
preview = { currentPreview() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package de.jeanlucmakiola.calendula.ui.common
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
@@ -23,7 +22,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||
@@ -32,6 +30,7 @@ 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
|
||||
@@ -73,10 +72,13 @@ suspend fun CalendarRepository.deleteEvent(
|
||||
/** How a [tryDeleteEvent] ended. */
|
||||
enum class DeleteOutcome { Deleted, Gone, NeedsPermission, Failed }
|
||||
|
||||
/** Whether this install may read the calendar at all. */
|
||||
fun Context.canReadCalendar(): Boolean =
|
||||
hasPermission(Manifest.permission.READ_CALENDAR)
|
||||
|
||||
/** Whether this install may write to the calendar; v1.0 installs only hold read access. */
|
||||
fun Context.canWriteCalendar(): Boolean =
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_CALENDAR) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
hasPermission(Manifest.permission.WRITE_CALENDAR)
|
||||
|
||||
/** [deleteEvent], with the provider's failures mapped to a [DeleteOutcome]. */
|
||||
suspend fun CalendarRepository.tryDeleteEvent(
|
||||
|
||||
@@ -6,7 +6,6 @@ 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.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
@@ -15,16 +14,16 @@ 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.SharingStarted
|
||||
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.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.datetime.TimeZone
|
||||
@@ -50,9 +49,8 @@ class EventActionsViewModel @Inject constructor(
|
||||
.map { list -> list.associateBy { it.id } }
|
||||
.catch { emit(emptyMap()) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = emptyMap(),
|
||||
)
|
||||
|
||||
|
||||
@@ -212,11 +212,21 @@ private fun rememberPickUpSurface(
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
||||
@@ -9,7 +9,6 @@ import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ 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
|
||||
@@ -23,22 +22,21 @@ 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
|
||||
@@ -189,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(),
|
||||
)
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ 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
|
||||
@@ -30,10 +32,13 @@ import kotlin.time.toJavaInstant
|
||||
@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(event.start, event.end, use24Hour, locale) {
|
||||
formatSelectionWhen(event, use24Hour, locale)
|
||||
val whenText = remember(shown.start, shown.end, use24Hour, locale) {
|
||||
formatSelectionWhen(shown, use24Hour, locale)
|
||||
}
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
|
||||
@@ -9,6 +9,7 @@ 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"),
|
||||
@@ -37,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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ 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.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -19,8 +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
|
||||
@@ -389,17 +390,20 @@ internal fun resizedSpan(
|
||||
}
|
||||
if (wrongWay) return startMin to endMin
|
||||
}
|
||||
val snapped = (edgeMin / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
|
||||
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 (minutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
|
||||
return snapToSlot(minutes)
|
||||
}
|
||||
|
||||
/** How far past its column's side the finger has to go before a resize changes day. */
|
||||
@@ -467,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
|
||||
@@ -618,25 +626,8 @@ class TimelineDragController {
|
||||
*/
|
||||
private var originSlot: Triple<LocalDate, Int, Int>? = null
|
||||
|
||||
/** The edge a resize is dragging, or null for a plain drag (#141). */
|
||||
private var resizeEdge: ResizeEdge? = null
|
||||
|
||||
/** The day the resized event starts on; its edges are minutes from that midnight. */
|
||||
private var resizeDay: LocalDate? = null
|
||||
private var resizeStartMin = 0
|
||||
private var resizeEndMin = 0
|
||||
|
||||
/** How far the finger sits past the edge it grabbed, so the edge doesn't jump to it. */
|
||||
private var resizeGrabMin = 0f
|
||||
|
||||
/** The column the resized edge is in, held until the finger is well into another. */
|
||||
private var resizeColumn: Int? = null
|
||||
|
||||
/** The slot the resized edge sits on, held against a finger trembling between two. */
|
||||
private var resizeSlot: Int? = null
|
||||
|
||||
/** The day, counted from the event's start day, the finger is resizing in. */
|
||||
private var resizeEdgeDay = 0
|
||||
/** The resize in progress, or null for a plain drag (#141). */
|
||||
private var resize: ResizeSession? = null
|
||||
|
||||
fun begin(
|
||||
block: TimedBlock,
|
||||
@@ -683,10 +674,7 @@ class TimelineDragController {
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
resizeEdge = null
|
||||
resizeDay = null
|
||||
resizeColumn = null
|
||||
resizeSlot = null
|
||||
resize = null
|
||||
source = null
|
||||
clipOffsetMin = 0
|
||||
dragZone = null
|
||||
@@ -761,6 +749,12 @@ class TimelineDragController {
|
||||
if (landed == null || landed.slot == origin) return null
|
||||
// A draft dropped on a day off screen would vanish, so it stays where it was (#153).
|
||||
if (landed.edgeOnly && landed.event.isDraft) return null
|
||||
settle(landed)
|
||||
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset)
|
||||
}
|
||||
|
||||
/** Keep [landed]'s copy on the target as [settling] until [release]; [end] is a resize's. */
|
||||
private fun settle(landed: TimelineDrag, end: Instant? = null) {
|
||||
settlingSelected = selection?.event?.instanceId == landed.event.instanceId
|
||||
// An edge piece is a hint for the finger, not a preview of the landing
|
||||
// slot: the event belongs to a day this timeline has no column for, so
|
||||
@@ -770,7 +764,8 @@ 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++
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -785,11 +780,9 @@ class TimelineDragController {
|
||||
pointerInRoot: Offset,
|
||||
) {
|
||||
val span = wallClockSpan(block.event, zone)
|
||||
val session = ResizeSession(edge, span.day, span.startMin, span.endMin)
|
||||
source = block
|
||||
resizeEdge = edge
|
||||
resizeDay = span.day
|
||||
resizeStartMin = span.startMin
|
||||
resizeEndMin = span.endMin
|
||||
resize = session
|
||||
clipOffsetMin = 0
|
||||
this.titleLines = titleLines
|
||||
settling = null
|
||||
@@ -798,17 +791,36 @@ class TimelineDragController {
|
||||
liftedInstanceId = block.event.instanceId
|
||||
pointer = pointerInRoot
|
||||
// Measured from the edge's own column: the handle overhangs into the next.
|
||||
val edgeDay = if (edge == ResizeEdge.Top) 0 else (resizeEndMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0)
|
||||
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))
|
||||
resizeColumn = edgeIndex.takeIf { it >= 0 }
|
||||
?.let { if (geometry.isRtl) geometry.days.lastIndex - it else it }
|
||||
resizeGrabMin = (pointerResizeMinutes() ?: 0f) -
|
||||
if (edge == ResizeEdge.Top) resizeStartMin else resizeEndMin
|
||||
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.
|
||||
@@ -816,16 +828,11 @@ class TimelineDragController {
|
||||
fun finishResize(zone: TimeZone): TimelineResizeDrop? {
|
||||
val landed = drag
|
||||
val origin = originSlot
|
||||
val edge = resizeEdge
|
||||
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)
|
||||
settlingSelected = selection?.event?.instanceId == landed.event.instanceId
|
||||
settling = if (landed.edgeOnly) landed.copy(pieces = emptyList()) else landed
|
||||
liftedInstanceId = landed.event.instanceId
|
||||
settledOrigin = landed.event.eventId to landed.event.start
|
||||
settledAbandonedMark = abandonedTicks?.value ?: 0
|
||||
settledEnd = drop.end
|
||||
settle(landed, end = drop.end)
|
||||
return drop
|
||||
}
|
||||
|
||||
@@ -833,36 +840,33 @@ class TimelineDragController {
|
||||
* Where the finger is, in minutes from the resized event's start day —
|
||||
* past 1440 in a later column, negative in an earlier one.
|
||||
*/
|
||||
private fun pointerResizeMinutes(): Float? {
|
||||
private fun pointerResizeMinutes(session: ResizeSession): Float? {
|
||||
val grid = geometry.grid?.takeIf { it.isAttached } ?: return null
|
||||
val day = resizeDay ?: return null
|
||||
val hourPx = geometry.hourPx
|
||||
val columnPx = geometry.columnWidthPx
|
||||
val days = geometry.days
|
||||
if (hourPx <= 0f || columnPx <= 0f || days.isEmpty()) return null
|
||||
val origin = grid.positionInRoot()
|
||||
val column = stickyColumn(
|
||||
val held = stickyColumn(
|
||||
position = (pointer.x - origin.x) / columnPx,
|
||||
held = resizeColumn,
|
||||
held = session.column,
|
||||
lastIndex = days.lastIndex,
|
||||
)
|
||||
resizeColumn = column
|
||||
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
|
||||
val dayOffset = (days[dayIndex].toEpochDays() - day.toEpochDays()).toInt()
|
||||
resizeEdgeDay = dayOffset
|
||||
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(edge: ResizeEdge) {
|
||||
private fun recomputeResize(session: ResizeSession) {
|
||||
val block = source ?: return
|
||||
val day = resizeDay ?: return
|
||||
val raw = pointerResizeMinutes() ?: return
|
||||
val slot = stickySnap(raw - resizeGrabMin, resizeSlot)
|
||||
resizeSlot = slot
|
||||
val (start, end) = resizedSpan(edge, resizeStartMin, resizeEndMin, slot.toFloat(), resizeEdgeDay)
|
||||
val 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 hourPx = geometry.hourPx
|
||||
val columnPx = geometry.columnWidthPx
|
||||
val origin = geometry.grid?.takeIf { it.isAttached }?.positionInRoot() ?: return
|
||||
// Which column the start day sits in, possibly off either end of the view.
|
||||
val startIndex = (day.toEpochDays() - days.first().toEpochDays()).toInt()
|
||||
@@ -875,20 +879,7 @@ class TimelineDragController {
|
||||
eventStartMin = start,
|
||||
eventSpanMin = end - start,
|
||||
titleLines = titleLines,
|
||||
pieces = slices.map { slice ->
|
||||
val dayIndex = startIndex + slice.dayOffset
|
||||
val col = if (geometry.isRtl) days.lastIndex - dayIndex else dayIndex
|
||||
val bounds = dragPieceBounds(slice, hourPx, geometry.blockInsetPx)
|
||||
TimelineDragPiece(
|
||||
topLeftInRoot = Offset(x = origin.x + col * columnPx, y = origin.y + bounds.top),
|
||||
sizePx = IntSize(
|
||||
(columnPx - geometry.columnGapPx).roundToInt(),
|
||||
bounds.height.roundToInt(),
|
||||
),
|
||||
continuesBefore = slice.continuesBefore,
|
||||
continuesAfter = slice.continuesAfter,
|
||||
)
|
||||
},
|
||||
pieces = pieces(slices, startIndex, origin),
|
||||
edgeOnly = slices.isEmpty(),
|
||||
markerMin = if (edge == ResizeEdge.Top) {
|
||||
start.mod(MINUTES_PER_DAY)
|
||||
@@ -933,7 +924,7 @@ class TimelineDragController {
|
||||
* no move events while auto-scroll walks the content underneath it.
|
||||
*/
|
||||
fun recompute() {
|
||||
resizeEdge?.let {
|
||||
resize?.let {
|
||||
recomputeResize(it)
|
||||
return
|
||||
}
|
||||
@@ -946,10 +937,9 @@ 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 snapped = snapToSlot(rawMinutes)
|
||||
val zone = dragZone ?: return
|
||||
val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex)
|
||||
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
|
||||
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
|
||||
@@ -977,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
|
||||
@@ -1031,6 +1027,27 @@ 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() }
|
||||
@@ -1160,7 +1177,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
|
||||
|
||||
// Both live here rather than beside the controller: they read [drag], which
|
||||
// changes every frame, and this composable is the one that is meant to.
|
||||
DragSnapHaptics(controller.drag?.slot)
|
||||
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) {
|
||||
|
||||
@@ -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)
|
||||
@@ -8,7 +8,6 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -17,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
|
||||
@@ -58,7 +56,6 @@ 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.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
@@ -87,11 +84,13 @@ import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.InlineStyle
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.domain.RichText
|
||||
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
|
||||
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
||||
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
|
||||
@@ -104,7 +103,6 @@ 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.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
@@ -304,7 +302,7 @@ private fun EventDetailContent(
|
||||
val detail = state.detail
|
||||
val instance = detail.instance
|
||||
val dark = LocalFloretDarkTheme.current
|
||||
val locale = currentDetailLocale()
|
||||
val locale = currentLocale()
|
||||
val accent = eventAccent(instance.color, dark, LocalSoftenColors.current)
|
||||
|
||||
Column(
|
||||
@@ -385,7 +383,7 @@ private fun EventDetailContent(
|
||||
|
||||
// "When" — date/all-day plus the time range.
|
||||
val (whenPrimary, whenSecondary) = rememberWhen(instance, TimeZone.currentSystemDefault(), locale)
|
||||
DetailCard(icon = Icons.Default.Schedule, iconContentDescription = null) {
|
||||
IconInfoCard(icon = Icons.Default.Schedule, iconContentDescription = null) {
|
||||
Text(text = whenPrimary, style = MaterialTheme.typography.titleMedium)
|
||||
if (whenSecondary != null) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
@@ -410,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),
|
||||
) {
|
||||
@@ -423,7 +421,7 @@ 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()
|
||||
val originalTime = zoneOrNull(zoneOption.id)
|
||||
?.let { rememberWhen(instance, it, locale) }
|
||||
?.let { (primary, secondary) -> secondary ?: primary }
|
||||
Spacer(Modifier.height(2.dp))
|
||||
@@ -442,13 +440,13 @@ 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,
|
||||
)
|
||||
@@ -460,7 +458,7 @@ private fun EventDetailContent(
|
||||
val context = LocalContext.current
|
||||
val locationLabel = stringResource(R.string.event_detail_location)
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
IconInfoCard(
|
||||
icon = Icons.Default.Place,
|
||||
iconContentDescription = locationLabel,
|
||||
) {
|
||||
@@ -487,7 +485,7 @@ private fun EventDetailContent(
|
||||
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
|
||||
@@ -506,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),
|
||||
) {
|
||||
@@ -528,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),
|
||||
) {
|
||||
@@ -537,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),
|
||||
)
|
||||
@@ -548,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),
|
||||
) {
|
||||
@@ -561,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(
|
||||
@@ -714,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
|
||||
@@ -738,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
|
||||
|
||||
@@ -6,7 +6,6 @@ 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
|
||||
@@ -17,11 +16,12 @@ 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 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
|
||||
@@ -30,7 +30,6 @@ 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
|
||||
@@ -76,9 +75,8 @@ class EventDetailViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = EventDetailUiState.Loading,
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,6 @@ 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.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
@@ -23,7 +22,6 @@ import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.endInstant
|
||||
import de.jeanlucmakiola.calendula.domain.nextFullHour
|
||||
import de.jeanlucmakiola.calendula.domain.toFormTime
|
||||
import de.jeanlucmakiola.calendula.domain.resolvedZone
|
||||
@@ -43,6 +41,8 @@ import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_O
|
||||
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.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -339,9 +339,8 @@ class EventEditViewModel @Inject constructor(
|
||||
allowColorOnUnsupportedCalendars = external.allowColorOnUnsupported,
|
||||
)
|
||||
}
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
|
||||
@@ -20,8 +20,10 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarAccountGroup
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.sourceAppName
|
||||
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
@@ -43,6 +45,7 @@ fun CalendarFilterList(
|
||||
is FilterUiState.Failure -> FilterMessage(s.reason, modifier)
|
||||
is FilterUiState.Success -> FilterList(
|
||||
groups = s.groups,
|
||||
hidden = s.hidden,
|
||||
onSetVisible = viewModel::setVisible,
|
||||
modifier = modifier,
|
||||
)
|
||||
@@ -56,23 +59,15 @@ fun CalendarFilterList(
|
||||
*/
|
||||
@Composable
|
||||
private fun FilterList(
|
||||
groups: List<AccountGroup>,
|
||||
groups: List<CalendarAccountGroup>,
|
||||
hidden: Set<Long>,
|
||||
onSetVisible: (Long, Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
groups.forEach { group ->
|
||||
val account = group.account.ifBlank { stringResource(R.string.calendar_unnamed) }
|
||||
Text(
|
||||
text = if (group.ambiguous) {
|
||||
stringResource(
|
||||
R.string.calendars_account_from_source,
|
||||
account,
|
||||
sourceAppName(group.accountType),
|
||||
)
|
||||
} else {
|
||||
account
|
||||
},
|
||||
text = accountGroupTitle(group),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(
|
||||
@@ -83,18 +78,19 @@ private fun FilterList(
|
||||
),
|
||||
)
|
||||
group.calendars.forEachIndexed { index, cal ->
|
||||
val visible = cal.id !in hidden
|
||||
GroupedRow(
|
||||
title = cal.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
title = cal.displayName.orUnnamed(),
|
||||
position = positionOf(index, group.calendars.size),
|
||||
minHeight = 56.dp,
|
||||
leading = { CalendarColorChip(cal.color) },
|
||||
trailing = {
|
||||
Checkbox(
|
||||
checked = cal.visible,
|
||||
checked = visible,
|
||||
onCheckedChange = { onSetVisible(cal.id, it) },
|
||||
)
|
||||
},
|
||||
onClick = { onSetVisible(cal.id, !cal.visible) },
|
||||
onClick = { onSetVisible(cal.id, !visible) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.ui.filter
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarAccountGroup
|
||||
|
||||
/**
|
||||
* State for the calendar-filter sheet (M3). The user toggles per-calendar
|
||||
@@ -10,26 +11,9 @@ import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
sealed interface FilterUiState {
|
||||
data object Loading : FilterUiState
|
||||
data class Failure(val reason: FailureReason) : FilterUiState
|
||||
data class Success(val groups: List<AccountGroup>) : FilterUiState
|
||||
/** A calendar shows when its id is *not* in [hidden]. */
|
||||
data class Success(
|
||||
val groups: List<CalendarAccountGroup>,
|
||||
val hidden: Set<Long>,
|
||||
) : FilterUiState
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendars grouped under the account that owns them (Nextcloud / Local / …).
|
||||
*
|
||||
* [accountType] and [ambiguous] carry what the header needs to tell two
|
||||
* same-named accounts from different apps apart (#77); the label itself is
|
||||
* built in the UI layer, which is where the source app's name can be looked up.
|
||||
*/
|
||||
data class AccountGroup(
|
||||
val account: String,
|
||||
val accountType: String,
|
||||
val ambiguous: Boolean,
|
||||
val calendars: List<CalendarRow>,
|
||||
)
|
||||
|
||||
data class CalendarRow(
|
||||
val id: Long,
|
||||
val displayName: String,
|
||||
val color: Int,
|
||||
val visible: Boolean,
|
||||
)
|
||||
|
||||
@@ -4,20 +4,18 @@ 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.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.calendarListFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
||||
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -40,14 +38,13 @@ class FilterViewModel @Inject constructor(
|
||||
if (failure != null) {
|
||||
FilterUiState.Failure(failure)
|
||||
} else {
|
||||
FilterUiState.Success(groupCalendarsForFilter(enabled, hidden))
|
||||
FilterUiState.Success(enabled.groupByAccount(), hidden)
|
||||
}
|
||||
}
|
||||
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = FilterUiState.Loading,
|
||||
)
|
||||
|
||||
@@ -61,28 +58,3 @@ class FilterViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Group calendars under their owning account — by name *and* type, so two
|
||||
* accounts that merely share a name stay apart (#77) — preserving the
|
||||
* provider's order within each group and ordering groups by first appearance.
|
||||
* A calendar is "visible" when its id is *not* in [hidden].
|
||||
*/
|
||||
internal fun groupCalendarsForFilter(
|
||||
calendars: List<CalendarSource>,
|
||||
hidden: Set<Long>,
|
||||
): List<AccountGroup> =
|
||||
calendars.groupByAccount().map { group ->
|
||||
AccountGroup(
|
||||
account = group.label,
|
||||
accountType = group.accountType,
|
||||
ambiguous = group.ambiguous,
|
||||
calendars = group.calendars.map { c ->
|
||||
CalendarRow(
|
||||
id = c.id,
|
||||
displayName = c.displayName,
|
||||
color = c.color,
|
||||
visible = c.id !in hidden,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ 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.ics.IcsImporter
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
@@ -15,6 +14,7 @@ import de.jeanlucmakiola.calendula.domain.ics.IcsParser
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
|
||||
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
@@ -7,7 +7,6 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberInlineTimeWidth
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
@@ -23,8 +22,7 @@ internal fun monthChipTime(
|
||||
locale: Locale,
|
||||
): String? {
|
||||
if (event.isAllDay || continuesLeft) return null
|
||||
val start = event.start.toLocalDateTime(zone).time
|
||||
return formatTimeOfDay(start.hour, start.minute, is24Hour, locale)
|
||||
return formatTimeOfDay(event.start, zone, is24Hour, locale)
|
||||
}
|
||||
|
||||
/** The narrowest chip that may show a time — the text it needs, plus its chrome. */
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -133,6 +134,10 @@ class MonthDragController {
|
||||
var settling: MonthChipDrag? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/** How many real drops this controller has handed back. */
|
||||
var drops by mutableIntStateOf(0)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Which chip is lifted, and whether anything is. Separate snapshot state from
|
||||
* [drag], which changes every frame, so the chips and rows that only need
|
||||
@@ -291,6 +296,7 @@ class MonthDragController {
|
||||
settledGhost = true
|
||||
undoable = settling
|
||||
settledAbandonedMark = abandonedTicks?.value ?: 0
|
||||
drops++
|
||||
return MonthChipDrop(landed.event, landed.grabDate, target)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.agendaDays
|
||||
import de.jeanlucmakiola.calendula.ui.common.PublishChrome
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScreenActions
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScreenBody
|
||||
@@ -21,7 +23,6 @@ import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
|
||||
import de.jeanlucmakiola.calendula.ui.common.morphDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.morphEvent
|
||||
import de.jeanlucmakiola.calendula.ui.common.morphPageActive
|
||||
import de.jeanlucmakiola.calendula.ui.common.jumpToPage
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSpec
|
||||
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||
@@ -67,7 +68,6 @@ import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -75,11 +75,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.InputChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -167,8 +163,6 @@ import de.jeanlucmakiola.calendula.ui.agenda.agendaDayLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
|
||||
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.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||
import de.jeanlucmakiola.calendula.ui.common.isPastOn
|
||||
@@ -198,10 +192,13 @@ import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.toJavaDayOfWeek
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlin.time.Instant
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
import kotlinx.datetime.toJavaMonth
|
||||
import kotlinx.datetime.yearMonth
|
||||
|
||||
private val LocalTodayPulse = staticCompositionLocalOf { 0 }
|
||||
|
||||
@@ -350,7 +347,7 @@ fun MonthScreen(
|
||||
if (dense) {
|
||||
val midweek = weekStartForIndex(listState.firstVisibleItemIndex + 1, weekStart)
|
||||
.plus(3, DateTimeUnit.DAY)
|
||||
YearMonth(midweek.year, midweek.month)
|
||||
midweek.yearMonth
|
||||
} else {
|
||||
yearMonthForIndex(monthIndexForItem(listState.firstVisibleItemIndex))
|
||||
}
|
||||
@@ -399,7 +396,7 @@ fun MonthScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val isOnCurrentMonth = titleMonth == YearMonth(today.year, today.month)
|
||||
val isOnCurrentMonth = titleMonth == today.yearMonth
|
||||
var splitExpanded by rememberSaveable(viewStyle) {
|
||||
mutableStateOf(viewStyle == MonthViewStyle.Paged)
|
||||
}
|
||||
@@ -428,7 +425,7 @@ fun MonthScreen(
|
||||
val jumpToToday: () -> Unit = {
|
||||
todayPulse++
|
||||
if (scrolling) {
|
||||
val targetMonth = YearMonth(today.year, today.month)
|
||||
val targetMonth = today.yearMonth
|
||||
pendingScrollMonth = targetMonth
|
||||
val requestId = ++scrollRequestId
|
||||
viewModel.goToToday()
|
||||
@@ -446,13 +443,13 @@ fun MonthScreen(
|
||||
}
|
||||
} else {
|
||||
viewModel.goToToday()
|
||||
jumpToMonth(YearMonth(today.year, today.month))
|
||||
jumpToMonth(today.yearMonth)
|
||||
}
|
||||
}
|
||||
// Drawer jump-to-date.
|
||||
val jumpToDate: (LocalDate) -> Unit = { target ->
|
||||
if (scrolling) {
|
||||
val targetMonth = YearMonth(target.year, target.month)
|
||||
val targetMonth = target.yearMonth
|
||||
pendingScrollMonth = targetMonth
|
||||
val requestId = ++scrollRequestId
|
||||
viewModel.goToDate(target)
|
||||
@@ -470,7 +467,7 @@ fun MonthScreen(
|
||||
}
|
||||
} else {
|
||||
viewModel.goToDate(target)
|
||||
jumpToMonth(YearMonth(target.year, target.month))
|
||||
jumpToMonth(target.yearMonth)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,7 +531,7 @@ fun MonthScreen(
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
} else if (viewStyle == MonthViewStyle.Split || viewStyle == MonthViewStyle.Paged) {
|
||||
} else {
|
||||
SplitMonthContent(
|
||||
state = state,
|
||||
selected = selectedDate,
|
||||
@@ -554,23 +551,12 @@ fun MonthScreen(
|
||||
// still settling would land and take the tap back.
|
||||
onSelectDay = { date ->
|
||||
viewModel.selectDate(date)
|
||||
jumpToMonth(YearMonth(date.year, date.month))
|
||||
jumpToMonth(date.yearMonth)
|
||||
},
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = { onCreateEvent(it, null) },
|
||||
)
|
||||
} else {
|
||||
MonthContent(
|
||||
state = state,
|
||||
pagerState = pagerState,
|
||||
weekStart = weekStart,
|
||||
monthState = viewModel::month,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onRetry = jumpToToday,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -596,7 +582,7 @@ private fun MonthDragOverlay(controller: MonthDragController) {
|
||||
val moveInFlight = moveInFlight()
|
||||
// Here rather than beside the controller: this reads [drag], which changes
|
||||
// every frame, and this composable is the one that is meant to.
|
||||
DragSnapHaptics(controller.drag?.targetDate)
|
||||
DragSnapHaptics(controller.drag?.targetDate, controller.drops)
|
||||
// Hold the landed chip until the write is done — including the time a
|
||||
// recurring drop's scope dialog is up — and then, unlike the timeline, carry
|
||||
// it to its seat: it was dropped at whatever height the finger was at, while
|
||||
@@ -679,118 +665,6 @@ private fun MonthDragOverlay(controller: MonthDragController) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonthContent(
|
||||
state: MonthUiState,
|
||||
pagerState: PagerState,
|
||||
weekStart: DayOfWeek,
|
||||
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
|
||||
showWeekNumbers: Boolean,
|
||||
onRetry: () -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
) {
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
// Loading and failure concern the provider as a whole, so they stand in for
|
||||
// the pager; a page still loading its own month handles that itself.
|
||||
AnimatedContent(
|
||||
targetState = state,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentKey = { it::class },
|
||||
transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) },
|
||||
label = "month-state",
|
||||
) { s ->
|
||||
when (s) {
|
||||
MonthUiState.Loading -> MonthGridLoading()
|
||||
is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||
is MonthUiState.Success -> MonthPager(
|
||||
pagerState = pagerState,
|
||||
weekStart = weekStart,
|
||||
monthState = monthState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) { _, page ->
|
||||
MonthGrid(
|
||||
state = page,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The paged and split styles' months as pages that follow the finger and snap
|
||||
* once a swipe passes half a page or is flung (#336). Only the page on screen
|
||||
* carries the chip drag and the morph tags: its neighbours show some of its
|
||||
* dates among their leading and trailing days, and would otherwise claim its
|
||||
* drops and its shared elements.
|
||||
*/
|
||||
@Composable
|
||||
private fun MonthPager(
|
||||
pagerState: PagerState,
|
||||
weekStart: DayOfWeek,
|
||||
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
|
||||
modifier: Modifier = Modifier,
|
||||
loading: @Composable () -> Unit = { MonthGridLoading() },
|
||||
page: @Composable (index: Int, state: MonthUiState.Success) -> Unit,
|
||||
) {
|
||||
val chipDrag = LocalMonthDrag.current
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = modifier,
|
||||
// The months either side are composed ahead, so a swipe lands on a loaded
|
||||
// month rather than watching it fill in.
|
||||
beyondViewportPageCount = 1,
|
||||
verticalAlignment = Alignment.Top,
|
||||
// A held chip moves within its month; the page stays put under it.
|
||||
userScrollEnabled = chipDrag?.isDragging != true,
|
||||
flingBehavior = PagerDefaults.flingBehavior(
|
||||
state = pagerState,
|
||||
snapAnimationSpec = rememberCalendarPageSpec(),
|
||||
),
|
||||
) { index ->
|
||||
MonthPage(
|
||||
index = index,
|
||||
active = index == pagerState.currentPage,
|
||||
morphActive = morphPageActive(index, pagerState.settledPage),
|
||||
weekStart = weekStart,
|
||||
monthState = monthState,
|
||||
loading = loading,
|
||||
page = page,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonthPage(
|
||||
index: Int,
|
||||
active: Boolean,
|
||||
morphActive: Boolean,
|
||||
weekStart: DayOfWeek,
|
||||
monthState: (YearMonth, DayOfWeek) -> StateFlow<MonthUiState>,
|
||||
loading: @Composable () -> Unit,
|
||||
page: @Composable (index: Int, state: MonthUiState.Success) -> Unit,
|
||||
) {
|
||||
val ym = yearMonthForIndex(index)
|
||||
val state by remember(ym, weekStart) { monthState(ym, weekStart) }.collectAsStateWithLifecycle()
|
||||
val today = LocalMonthToday.current
|
||||
val chipDrag = LocalMonthDrag.current
|
||||
val morph = LocalMonthMorph.current
|
||||
val viewMorph = LocalViewMorph.current
|
||||
CompositionLocalProvider(
|
||||
LocalMonthDrag provides chipDrag.takeIf { active },
|
||||
LocalMonthMorph provides morph.takeIf { active },
|
||||
LocalViewMorph provides viewMorph.takeIf { morphActive },
|
||||
) {
|
||||
when (val s = state) {
|
||||
is MonthUiState.Success -> page(index, s.withToday(today))
|
||||
else -> loading()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Content for both scrolling styles. No swipe detector and no [AnimatedContent]:
|
||||
* vertical scrolling owns the gesture, and the list never swaps wholesale — it
|
||||
@@ -850,7 +724,7 @@ internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
|
||||
if (showWeekNumbers) Spacer(Modifier.width(rememberWeekNumberGutter()))
|
||||
days.forEach { dow ->
|
||||
val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY
|
||||
val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1)
|
||||
val javaDow = dow.toJavaDayOfWeek()
|
||||
Text(
|
||||
text = javaDow.getDisplayName(JavaTextStyle.SHORT, locale),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
@@ -967,7 +841,7 @@ internal fun monthBandHeight(rowHeight: Dp): Dp =
|
||||
|
||||
/** Whether any day in this week holds more than [lanes] lanes can seat. */
|
||||
private fun MonthWeek.overflowsAt(lanes: Int): Boolean =
|
||||
days.withIndex().any { (col, day) -> overflowEvents(col, day, lanes).isNotEmpty() }
|
||||
days.indices.any { col -> overflowEvents(col, lanes).isNotEmpty() }
|
||||
|
||||
/**
|
||||
* Row height in the continuous grid. The paged grid divides the viewport between
|
||||
@@ -1069,7 +943,7 @@ internal fun ContinuousMonthGrid(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val monthCount = remember { continuousMonthCount() }
|
||||
val todayMonth = remember(state.today) { YearMonth(state.today.year, state.today.month) }
|
||||
val todayMonth = remember(state.today) { state.today.yearMonth }
|
||||
val timeChipWidth = rememberMonthTimeChipWidth()
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
@@ -1463,8 +1337,14 @@ private fun SplitMonthContent(
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
expandDrag = expandDrag,
|
||||
onSelectDay = onSelectDay,
|
||||
onPaneSwipeNext = onPaneSwipeNext,
|
||||
onPaneSwipePrev = onPaneSwipePrev,
|
||||
onPaneSwipeNext = {
|
||||
onPaneSwipeNext()
|
||||
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
||||
},
|
||||
onPaneSwipePrev = {
|
||||
onPaneSwipePrev()
|
||||
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
||||
},
|
||||
onClearSelection = onClearSelection,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
@@ -1747,11 +1627,11 @@ internal fun SplitMonthGrid(
|
||||
val inMonth = day.month == month.month && day.year == month.year
|
||||
// Seated by lane rather than gathered by colour, so each dot
|
||||
// is the event the expanded grid draws in that same lane.
|
||||
val seated = week.laneEvents(col, day, SPLIT_DOT_LANES)
|
||||
val seated = week.laneEvents(col, SPLIT_DOT_LANES)
|
||||
SplitDayCell(
|
||||
date = day,
|
||||
events = seated,
|
||||
hidden = week.overflowEvents(col, day, SPLIT_DOT_LANES),
|
||||
hidden = week.overflowEvents(col, SPLIT_DOT_LANES),
|
||||
isToday = day == state.today,
|
||||
// A page marks only the days its own month owns. Paging
|
||||
// moves the selection before this month's replacement
|
||||
@@ -1961,7 +1841,6 @@ private fun SplitDots(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun SplitMonthAgendaPane(
|
||||
state: MonthUiState.Success,
|
||||
@@ -1979,33 +1858,16 @@ private fun SplitMonthAgendaPane(
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val listState = key(state.month) { rememberLazyListState() }
|
||||
LazyColumn(state = listState, contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp)) {
|
||||
days.forEach { day ->
|
||||
val events = state.instancesByDay[day].orEmpty()
|
||||
stickyHeader(key = "header-$day") {
|
||||
AgendaDayHeader(date = day, today = state.today, onOpenDay = onOpenDay)
|
||||
}
|
||||
if (events.isEmpty()) {
|
||||
item(key = "empty-$day") {
|
||||
AgendaEmptyDayRow(
|
||||
text = stringResource(R.string.month_split_no_events),
|
||||
onClick = { onCreateEvent(day) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
itemsIndexed(events, key = { _, event -> "$day-${event.instanceId}" }) { index, event ->
|
||||
AgendaEventRow(
|
||||
event = event,
|
||||
day = day,
|
||||
zone = state.zone,
|
||||
position = positionOf(index, events.size),
|
||||
dimmed = dimCutoff != null && event.isPastOn(day, dimCutoff),
|
||||
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day)),
|
||||
onClick = { onEventClick(event) },
|
||||
)
|
||||
}
|
||||
}
|
||||
item(key = "gap-$day") { Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
agendaDays(
|
||||
days = days.map { AgendaDay(it, state.instancesByDay[it].orEmpty()) },
|
||||
today = state.today,
|
||||
zone = state.zone,
|
||||
emptyText = R.string.month_split_no_events,
|
||||
onEmptyClick = onCreateEvent,
|
||||
dimmed = { event, day -> dimCutoff != null && event.isPastOn(day, dimCutoff) },
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2236,8 +2098,8 @@ private fun MonthWeekRow(
|
||||
val ripple = rememberTodayRipple(active = todayCol != null)
|
||||
val overflowRow = rememberOverflowRowHeight()
|
||||
val laneCap = week.laneCapFor(rowHeight, overflowRow)
|
||||
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
|
||||
val shownLanes = laneCount.coerceAtMost(laneCap)
|
||||
// Once per row rather than per frame: a drag recomposes every frame.
|
||||
val seatings = remember(week, laneCap) { week.days.indices.map { week.seating(it, laneCap) } }
|
||||
val morphing = morphInFlight()
|
||||
// Every chip's start time for this row at once, and only when the row's own
|
||||
// inputs change: formatting is a parsed pattern per call, and the dim cutoff
|
||||
@@ -2421,7 +2283,7 @@ private fun MonthWeekRow(
|
||||
.then(if (morphing || dragging) Modifier else Modifier.clipToBounds()),
|
||||
) {
|
||||
// Spanning bars on their shared lanes.
|
||||
week.spans.filter { it.lane < shownLanes }.forEach { span ->
|
||||
week.spans.filter { it.lane < laneCap }.forEach { span ->
|
||||
val cols = span.endCol - span.startCol + 1
|
||||
MonthBar(
|
||||
event = span.event,
|
||||
@@ -2493,19 +2355,13 @@ private fun MonthWeekRow(
|
||||
)
|
||||
}
|
||||
}
|
||||
// Single-day timed pills + overflow, per column. Pills fill the
|
||||
// lane slots no bar occupies on THIS day (top-most first), so a
|
||||
// bar-free day isn't pushed down by a multi-day event that only
|
||||
// sits on other days of the week.
|
||||
// Single-day timed pills + overflow, per column, in the lanes
|
||||
// [seating] gave them.
|
||||
week.days.forEachIndexed { col, d ->
|
||||
val timed = week.timedByDay[d].orEmpty()
|
||||
val occupied = week.spans
|
||||
.filter { it.lane < shownLanes && col in it.startCol..it.endCol }
|
||||
.map { it.lane }
|
||||
.toSet()
|
||||
val freeSlots = (0 until laneCap).filter { it !in occupied }
|
||||
val pillsShown = timed.take(freeSlots.size)
|
||||
pillsShown.forEachIndexed { i, ev ->
|
||||
val seating = seatings[col]
|
||||
seating.seated.forEach { seat ->
|
||||
if (seat.bar) return@forEach
|
||||
val ev = seat.event
|
||||
MonthBar(
|
||||
event = ev,
|
||||
dark = dark,
|
||||
@@ -2517,7 +2373,7 @@ private fun MonthWeekRow(
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
x = colW * col,
|
||||
y = EVENT_ROW_HEIGHT * freeSlots[i],
|
||||
y = EVENT_ROW_HEIGHT * seat.lane,
|
||||
)
|
||||
.morphBounds(MonthMorphKey.Event(d, ev.instanceId))
|
||||
.then(
|
||||
@@ -2533,18 +2389,10 @@ private fun MonthWeekRow(
|
||||
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
|
||||
if (hidden > 0) {
|
||||
val hiddenEvents = buildList {
|
||||
week.spans
|
||||
.filter { it.lane >= shownLanes && col in it.startCol..it.endCol }
|
||||
.forEach { add(it.event) }
|
||||
addAll(timed.drop(pillsShown.size))
|
||||
}
|
||||
if (seating.hidden.isNotEmpty()) {
|
||||
OverflowDots(
|
||||
events = hiddenEvents,
|
||||
events = seating.hidden,
|
||||
day = d,
|
||||
total = hidden,
|
||||
dark = dark,
|
||||
rowHeight = overflowRow,
|
||||
modifier = Modifier
|
||||
@@ -2839,7 +2687,7 @@ private fun DayNumberCell(
|
||||
private fun shortMonthName(date: LocalDate): String {
|
||||
val locale = currentLocale()
|
||||
return remember(date.month, locale) {
|
||||
java.time.Month.of(date.month.ordinal + 1)
|
||||
date.month.toJavaMonth()
|
||||
.getDisplayName(JavaTextStyle.SHORT, locale)
|
||||
}
|
||||
}
|
||||
@@ -2924,7 +2772,6 @@ private fun MonthBar(
|
||||
private fun OverflowDots(
|
||||
events: List<EventInstance>,
|
||||
day: LocalDate,
|
||||
total: Int,
|
||||
dark: Boolean,
|
||||
rowHeight: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -2946,7 +2793,7 @@ private fun OverflowDots(
|
||||
.background(eventAccent(argb, dark, soften), CircleShape),
|
||||
)
|
||||
}
|
||||
val extra = total - dots.size
|
||||
val extra = events.size - dots.size
|
||||
if (extra > 0) {
|
||||
Text(
|
||||
text = "+$extra",
|
||||
@@ -3002,7 +2849,7 @@ private fun formatMonthTitle(
|
||||
abbreviated: Boolean = false,
|
||||
): String =
|
||||
formatCalendarTitle(
|
||||
date = java.time.LocalDate.of(ym.year, ym.month.ordinal + 1, 1),
|
||||
date = ym.firstDay.toJavaLocalDate(),
|
||||
locale = locale,
|
||||
currentYear = currentYear,
|
||||
skeleton = if (abbreviated) "LLL" else "LLLL",
|
||||
|
||||
@@ -8,16 +8,16 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
|
||||
import de.jeanlucmakiola.calendula.ui.common.sampleMonthEvents
|
||||
import de.jeanlucmakiola.floret.components.ScaledViewPreview
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import kotlinx.datetime.yearMonth
|
||||
|
||||
/**
|
||||
* A live, scaled-down Month view in a given [MonthViewStyle], for the settings
|
||||
@@ -51,7 +51,7 @@ internal fun MonthStylePreview(
|
||||
state = sample.continuous,
|
||||
listState = rememberLazyListState(
|
||||
initialFirstVisibleItemIndex = itemIndexForMonth(
|
||||
monthIndexOf(YearMonth(today.year, today.month)),
|
||||
monthIndexOf(today.yearMonth),
|
||||
),
|
||||
),
|
||||
showWeekNumbers = false,
|
||||
@@ -109,7 +109,7 @@ private fun sampleMonthState(
|
||||
weekStart: DayOfWeek,
|
||||
zone: TimeZone,
|
||||
): SampleMonth {
|
||||
val ym = YearMonth(today.year, today.month)
|
||||
val ym = today.yearMonth
|
||||
val first = LocalDate(ym.year, ym.month, 1)
|
||||
val events = sampleMonthEvents(first, today, zone)
|
||||
|
||||
|
||||
@@ -39,33 +39,55 @@ data class MonthWeek(
|
||||
val countByDay: Map<LocalDate, Int>,
|
||||
)
|
||||
|
||||
/** One event seated in [lane] of a day; [bar] is a spanning bar rather than that day's own pill. */
|
||||
data class Seat(val lane: Int, val event: EventInstance, val bar: Boolean)
|
||||
|
||||
/**
|
||||
* The events occupying each lane of [day] — column [col] of this week — in lane
|
||||
* order, capped at [laneCap] lanes.
|
||||
* How one day of a week row is seated with [laneCap] lanes: what sits in each
|
||||
* lane, top-most first with empty lanes left out, and what had no lane left.
|
||||
*/
|
||||
data class DaySeating(val seated: List<Seat>, val hidden: List<EventInstance>)
|
||||
|
||||
/**
|
||||
* The seating of column [col] with [laneCap] lanes — the one every reader of a
|
||||
* row builds on: the bars and pills the paged grid draws, the split style's dots
|
||||
* ([laneEvents]), the hit test ([chipAt]) and the overflow ([overflowEvents]).
|
||||
*
|
||||
* This is the same seating [spans]/[timedByDay] get when the paged grid draws a
|
||||
* week: bars keep the lane the row layout gave them, and the day's timed events
|
||||
* fill whatever slots are left, top-most first. Reading it here means the split
|
||||
* style's dots and the paged style's bars describe a day in the *same order*, so
|
||||
* dot _i_ and lane _i_ are the same event and one can morph into the other (#53).
|
||||
* Bars keep the lane the row layout gave them, and the day's timed events fill
|
||||
* whatever slots are left, top-most first, so a bar-free day isn't pushed down by
|
||||
* a multi-day event that only sits on other days of the week. Overflow is the
|
||||
* exact complement, bars before pills.
|
||||
*/
|
||||
fun MonthWeek.seating(col: Int, laneCap: Int): DaySeating {
|
||||
val covering = spans.filter { col in it.startCol..it.endCol }
|
||||
val byLane = arrayOfNulls<Seat>(laneCap.coerceAtLeast(0))
|
||||
covering.forEach { span ->
|
||||
if (span.lane < laneCap) byLane[span.lane] = Seat(span.lane, span.event, bar = true)
|
||||
}
|
||||
val timed = timedByDay[days[col]].orEmpty()
|
||||
var pills = 0
|
||||
for (lane in byLane.indices) {
|
||||
if (byLane[lane] == null && pills < timed.size) byLane[lane] = Seat(lane, timed[pills++], bar = false)
|
||||
}
|
||||
return DaySeating(
|
||||
seated = byLane.filterNotNull(),
|
||||
hidden = covering.filter { it.lane >= laneCap }.map { it.event } + timed.drop(pills),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The events occupying each lane of column [col], in lane order, capped at
|
||||
* [laneCap] lanes — the same [seating] the paged grid draws a week with. Reading
|
||||
* it here means the split style's dots and the paged style's bars describe a day
|
||||
* in the *same order*, so dot _i_ and lane _i_ are the same event and one can
|
||||
* morph into the other (#53).
|
||||
*
|
||||
* Deriving the dots independently — by distinct colour, as they first were —
|
||||
* left dot _i_ standing for no particular event, and quietly merged two events
|
||||
* that shared a calendar into a single dot.
|
||||
*/
|
||||
fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
|
||||
val byLane = arrayOfNulls<EventInstance>(laneCap)
|
||||
spans.forEach { span ->
|
||||
if (span.lane < laneCap && col in span.startCol..span.endCol) {
|
||||
byLane[span.lane] = span.event
|
||||
}
|
||||
}
|
||||
val free = (0 until laneCap).filter { byLane[it] == null }
|
||||
timedByDay[day].orEmpty().take(free.size).forEachIndexed { i, event ->
|
||||
byLane[free[i]] = event
|
||||
}
|
||||
return byLane.filterNotNull()
|
||||
}
|
||||
fun MonthWeek.laneEvents(col: Int, laneCap: Int): List<EventInstance> =
|
||||
seating(col, laneCap).seated.map { it.event }
|
||||
|
||||
/**
|
||||
* The event drawn in lane [lane] of column [col], or null for an empty slot.
|
||||
@@ -74,14 +96,7 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInst
|
||||
*/
|
||||
fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
|
||||
if (col !in days.indices || lane !in 0 until laneCap) return null
|
||||
spanAt(col, lane)?.let { return it.event }
|
||||
val occupied = spans
|
||||
.filter { it.lane < laneCap && col in it.startCol..it.endCol }
|
||||
.map { it.lane }
|
||||
.toSet()
|
||||
val free = (0 until laneCap).filter { it !in occupied }
|
||||
val index = free.indexOf(lane).takeIf { it >= 0 } ?: return null
|
||||
return timedByDay[days[col]].orEmpty().take(free.size).getOrNull(index)
|
||||
return seating(col, laneCap).seated.firstOrNull { it.lane == lane }?.event
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,19 +113,11 @@ fun MonthWeek.spanAt(col: Int, lane: Int): MonthSpan? =
|
||||
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
|
||||
|
||||
/**
|
||||
* The events on [day] that [laneEvents] had no lane left for — its exact
|
||||
* The events in column [col] that [laneEvents] had no lane left for — its exact
|
||||
* complement, in the same bars-then-pills order. Returned as events rather than
|
||||
* a count because dimming is a per-event question (#79).
|
||||
*/
|
||||
fun MonthWeek.overflowEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
|
||||
val seatedLanes = spans.count { it.lane < laneCap && col in it.startCol..it.endCol }
|
||||
return buildList {
|
||||
spans.forEach { span ->
|
||||
if (span.lane >= laneCap && col in span.startCol..span.endCol) add(span.event)
|
||||
}
|
||||
addAll(timedByDay[day].orEmpty().drop(laneCap - seatedLanes))
|
||||
}
|
||||
}
|
||||
fun MonthWeek.overflowEvents(col: Int, laneCap: Int): List<EventInstance> = seating(col, laneCap).hidden
|
||||
|
||||
/**
|
||||
* State for the continuous style (#38): a vertical stream of *self-contained*
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import de.jeanlucmakiola.calendula.ui.common.startOfWeek
|
||||
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.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
|
||||
@@ -19,10 +21,11 @@ import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
|
||||
import de.jeanlucmakiola.calendula.ui.week.coversDay
|
||||
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
|
||||
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
|
||||
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
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
@@ -33,22 +36,19 @@ import kotlinx.coroutines.flow.filterNot
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlin.math.abs
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.Month
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.daysUntil
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
import kotlinx.datetime.yearMonth
|
||||
|
||||
/** How many months either side of the requested one [MonthViewModel.month] keeps cached. */
|
||||
private const val MONTH_CACHE_SPAN = 2
|
||||
@@ -69,54 +69,35 @@ class MonthViewModel @Inject constructor(
|
||||
|
||||
/** Whether to fade events that have already finished (display concern only). */
|
||||
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
/** Whether to show the calendar-week number gutter (#25; display only). */
|
||||
val showWeekNumbers: StateFlow<Boolean> = settingsPrefs.showWeekNumbers
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
/** How the grid is laid out and navigated (#38, #53). */
|
||||
val viewStyle: StateFlow<MonthViewStyle> = settingsPrefs.monthViewStyle
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = MonthViewStyle.Paged,
|
||||
)
|
||||
|
||||
private val todayDate: LocalDate
|
||||
get() = currentDate()
|
||||
|
||||
private var lastToday = todayDate
|
||||
|
||||
/** Today, kept current across midnight so a grid left open doesn't mark yesterday (#373). */
|
||||
val today: StateFlow<LocalDate> = todaySource.today
|
||||
.onEach { date ->
|
||||
val old = lastToday
|
||||
lastToday = date
|
||||
if (old != date) followToday(old, date)
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), todayDate)
|
||||
val today: StateFlow<LocalDate> =
|
||||
todaySource.today.stateFollowingDays(viewModelScope, todayDate, ::followToday)
|
||||
|
||||
private val _month = MutableStateFlow(YearMonth(todayDate.year, todayDate.month))
|
||||
private val _month = MutableStateFlow(todayDate.yearMonth)
|
||||
val month: StateFlow<YearMonth> = _month
|
||||
|
||||
/** The pager page the anchor month sits on, for the paged and split styles. */
|
||||
val anchorPage: StateFlow<Int> = _month
|
||||
.map { monthIndexOf(it) }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = monthIndexOf(_month.value),
|
||||
)
|
||||
|
||||
/**
|
||||
* The anchor month's state. Once loaded it never falls back to
|
||||
* [MonthUiState.Loading]: moving the anchor keeps the last result until the
|
||||
@@ -133,9 +114,8 @@ class MonthViewModel @Inject constructor(
|
||||
month(ym, ws).filterNot { it is MonthUiState.Loading }
|
||||
}
|
||||
.catch { emit(MonthUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = MonthUiState.Loading,
|
||||
)
|
||||
|
||||
@@ -170,7 +150,8 @@ class MonthViewModel @Inject constructor(
|
||||
fun onPageSettled(page: Int) {
|
||||
if (monthIndexOf(_month.value) == page) return
|
||||
_month.value = yearMonthForIndex(page)
|
||||
realignSelection()
|
||||
// Moving the grid to another month restores its full agenda.
|
||||
_selectedDate.value = null
|
||||
}
|
||||
|
||||
// --- Continuous + Dense styles (#38) ----------------------------------
|
||||
@@ -195,11 +176,11 @@ class MonthViewModel @Inject constructor(
|
||||
|
||||
/** Last reported visible range, so a widening step can re-centre on it. */
|
||||
@Volatile
|
||||
private var visibleMonths = monthIndexOf(YearMonth(todayDate.year, todayDate.month))
|
||||
private var visibleMonths = monthIndexOf(todayDate.yearMonth)
|
||||
.let { it..it }
|
||||
|
||||
private val _loadedMonths = MutableStateFlow(
|
||||
monthIndexOf(YearMonth(todayDate.year, todayDate.month))
|
||||
monthIndexOf(todayDate.yearMonth)
|
||||
.let { clampMonthWindow(it - INITIAL_PAD..it + INITIAL_PAD) },
|
||||
)
|
||||
|
||||
@@ -215,13 +196,11 @@ class MonthViewModel @Inject constructor(
|
||||
// Widened to whole grid weeks at both ends: a month block still
|
||||
// has to know about an event that starts in the boundary week's
|
||||
// clipped-off days, or a bar running into the block would vanish.
|
||||
val first = firstOfMonth(yearMonthForIndex(window.first)).startOfGridWeek(ws)
|
||||
val last = firstOfMonth(yearMonthForIndex(window.last))
|
||||
.plus(1, DateTimeUnit.MONTH)
|
||||
.minus(1, DateTimeUnit.DAY)
|
||||
.startOfGridWeek(ws)
|
||||
val first = yearMonthForIndex(window.first).firstDay.startOfWeek(ws)
|
||||
val last = yearMonthForIndex(window.last).lastDay
|
||||
.startOfWeek(ws)
|
||||
.plus(6, DateTimeUnit.DAY)
|
||||
val range = first.atStartOfDayIn(zone)..last.atTime(23, 59, 59).toInstant(zone)
|
||||
val range = dayRange(first, last, zone)
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(range),
|
||||
@@ -236,9 +215,8 @@ class MonthViewModel @Inject constructor(
|
||||
.onEach { if (it is ContinuousMonthUiState.Success) widenLoadedWindow() }
|
||||
.catch { emit(ContinuousMonthUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = ContinuousMonthUiState.Loading,
|
||||
)
|
||||
|
||||
@@ -320,7 +298,7 @@ class MonthViewModel @Inject constructor(
|
||||
* silently list a date the grid isn't pointing at.
|
||||
*/
|
||||
fun selectDate(date: LocalDate) {
|
||||
val target = YearMonth(date.year, date.month)
|
||||
val target = date.yearMonth
|
||||
_selectedDate.value = if (target == _month.value && _selectedDate.value == date) null else date
|
||||
if (target != _month.value) _month.value = target
|
||||
}
|
||||
@@ -335,17 +313,12 @@ class MonthViewModel @Inject constructor(
|
||||
} else {
|
||||
val next = selected.plus(days, DateTimeUnit.DAY)
|
||||
_selectedDate.value = next
|
||||
_month.value = YearMonth(next.year, next.month)
|
||||
_month.value = next.yearMonth
|
||||
}
|
||||
}
|
||||
|
||||
/** Moving the grid to another month restores its full agenda. */
|
||||
private fun realignSelection() {
|
||||
_selectedDate.value = null
|
||||
}
|
||||
|
||||
fun goToToday() {
|
||||
_month.value = YearMonth(todayDate.year, todayDate.month)
|
||||
_month.value = todayDate.yearMonth
|
||||
_selectedDate.value = null
|
||||
}
|
||||
|
||||
@@ -356,8 +329,8 @@ class MonthViewModel @Inject constructor(
|
||||
*/
|
||||
private fun followToday(old: LocalDate, new: LocalDate) {
|
||||
if (viewStyle.value.isScrolling) return
|
||||
if (_month.value != YearMonth(old.year, old.month)) return
|
||||
_month.value = YearMonth(new.year, new.month)
|
||||
if (_month.value != old.yearMonth) return
|
||||
_month.value = new.yearMonth
|
||||
_selectedDate.value = null
|
||||
}
|
||||
|
||||
@@ -377,7 +350,7 @@ class MonthViewModel @Inject constructor(
|
||||
|
||||
/** Jump to the month containing [date] (drawer jump-to-date). */
|
||||
fun goToDate(date: LocalDate) {
|
||||
_month.value = YearMonth(date.year, date.month)
|
||||
_month.value = date.yearMonth
|
||||
_selectedDate.value = date
|
||||
}
|
||||
|
||||
@@ -427,7 +400,7 @@ internal fun layoutMonthWeeks(
|
||||
byDay: DayIndex,
|
||||
zone: TimeZone,
|
||||
): List<MonthWeek> {
|
||||
val gridStart = firstOfMonth(ym).startOfGridWeek(weekStart)
|
||||
val gridStart = ym.firstDay.startOfWeek(weekStart)
|
||||
val weekCount = weekRowsInMonth(ym, weekStart)
|
||||
|
||||
return (0 until weekCount).map { row ->
|
||||
@@ -548,12 +521,8 @@ internal fun monthGridRange(
|
||||
weekStart: DayOfWeek,
|
||||
zone: TimeZone,
|
||||
): ClosedRange<Instant> {
|
||||
val firstOfMonth = LocalDate(ym.year, ym.month, 1)
|
||||
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
|
||||
val gridEnd = gridStart.plus(41, DateTimeUnit.DAY)
|
||||
val start = gridStart.atStartOfDayIn(zone)
|
||||
val end = gridEnd.atTime(23, 59, 59).toInstant(zone)
|
||||
return start..end
|
||||
val gridStart = ym.firstDay.startOfWeek(weekStart)
|
||||
return dayRange(gridStart, gridStart.plus(41, DateTimeUnit.DAY), zone)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -581,7 +550,7 @@ internal fun edgeForPad(pad: Int): Int = minOf(WINDOW_EDGE, pad - 1).coerceAtLea
|
||||
* the first of the visible month.
|
||||
*/
|
||||
internal fun agendaStartForMonth(month: YearMonth, today: LocalDate): LocalDate =
|
||||
if (YearMonth(today.year, today.month) == month) {
|
||||
if (today.yearMonth == month) {
|
||||
today
|
||||
} else {
|
||||
LocalDate(month.year, month.month, 1)
|
||||
@@ -649,8 +618,6 @@ internal fun clampMonthWindow(window: IntRange): IntRange {
|
||||
return window.first.coerceIn(0, last)..window.last.coerceIn(0, last)
|
||||
}
|
||||
|
||||
internal fun firstOfMonth(ym: YearMonth): LocalDate = LocalDate(ym.year, ym.month, 1)
|
||||
|
||||
/**
|
||||
* The Dense style addresses *weeks* by absolute index instead: it has no month
|
||||
* blocks to key by, and a row's identity has to survive the months around it
|
||||
@@ -665,12 +632,12 @@ private val WEEK_INDEX_EPOCH = LocalDate(1900, 1, 1)
|
||||
private val WEEK_INDEX_END = LocalDate(2100, 12, 31)
|
||||
|
||||
internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int {
|
||||
val base = WEEK_INDEX_EPOCH.startOfGridWeek(weekStart)
|
||||
return base.daysUntil(date.startOfGridWeek(weekStart)) / 7
|
||||
val base = WEEK_INDEX_EPOCH.startOfWeek(weekStart)
|
||||
return base.daysUntil(date.startOfWeek(weekStart)) / 7
|
||||
}
|
||||
|
||||
internal fun weekStartForIndex(index: Int, weekStart: DayOfWeek): LocalDate =
|
||||
WEEK_INDEX_EPOCH.startOfGridWeek(weekStart).plus(index * 7, DateTimeUnit.DAY)
|
||||
WEEK_INDEX_EPOCH.startOfWeek(weekStart).plus(index * 7, DateTimeUnit.DAY)
|
||||
|
||||
/** Total weeks the Dense grid scrolls through (1900 → 2100). */
|
||||
internal fun continuousWeekCount(weekStart: DayOfWeek): Int =
|
||||
@@ -681,7 +648,7 @@ internal fun monthIndexForWeek(weekIndex: Int, weekStart: DayOfWeek): Int {
|
||||
// The row's midweek day: a boundary week is reported as whichever month owns
|
||||
// most of it, the same rule the title uses.
|
||||
val midweek = weekStartForIndex(weekIndex, weekStart).plus(3, DateTimeUnit.DAY)
|
||||
return monthIndexOf(YearMonth(midweek.year, midweek.month))
|
||||
return monthIndexOf(midweek.yearMonth)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -690,19 +657,16 @@ internal fun monthIndexForWeek(weekIndex: Int, weekStart: DayOfWeek): Int {
|
||||
* laid out too, rather than flickering as placeholders.
|
||||
*/
|
||||
internal fun weekWindowFor(window: IntRange, weekStart: DayOfWeek): IntRange {
|
||||
val first = weekIndexOf(firstOfMonth(yearMonthForIndex(window.first)), weekStart) - 1
|
||||
val lastMonthEnd = firstOfMonth(yearMonthForIndex(window.last))
|
||||
.plus(1, DateTimeUnit.MONTH)
|
||||
.minus(1, DateTimeUnit.DAY)
|
||||
val first = weekIndexOf(yearMonthForIndex(window.first).firstDay, weekStart) - 1
|
||||
val lastMonthEnd = yearMonthForIndex(window.last).lastDay
|
||||
return first..(weekIndexOf(lastMonthEnd, weekStart) + 1)
|
||||
}
|
||||
|
||||
/** How many week rows [ym] needs under [weekStart] — 4 (a non-leap February), 5 or 6. */
|
||||
internal fun weekRowsInMonth(ym: YearMonth, weekStart: DayOfWeek): Int {
|
||||
val first = firstOfMonth(ym)
|
||||
val first = ym.firstDay
|
||||
val leadOffset = ((first.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
||||
val daysInMonth = java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth()
|
||||
return (leadOffset + daysInMonth + 6) / 7
|
||||
return (leadOffset + ym.numberOfDays + 6) / 7
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -746,9 +710,3 @@ internal fun clipWeekToMonth(week: MonthWeek, month: YearMonth): MonthWeek {
|
||||
countByDay = week.countByDay.filterKeys { it in own },
|
||||
)
|
||||
}
|
||||
|
||||
internal fun LocalDate.startOfGridWeek(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)
|
||||
}
|
||||
|
||||
@@ -10,17 +10,14 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CloudOff
|
||||
import androidx.compose.material.icons.filled.FolderOpen
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||
import de.jeanlucmakiola.floret.components.OnboardingPrimaryButton
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||
|
||||
@@ -53,15 +50,10 @@ internal fun BackupStep(
|
||||
topSpacing = OnboardingSpace.lg,
|
||||
hero = { IconHero(Icons.Filled.CloudOff) },
|
||||
actions = {
|
||||
Button(
|
||||
OnboardingPrimaryButton(
|
||||
text = stringResource(R.string.onboarding_backup_enable_button),
|
||||
onClick = { runCatching { pickFolder.launch(null) } },
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_backup_enable_button),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
)
|
||||
TextButton(
|
||||
onClick = onSkip,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -69,20 +61,9 @@ internal fun BackupStep(
|
||||
Text(stringResource(R.string.onboarding_backup_skip_button))
|
||||
}
|
||||
},
|
||||
headline = stringResource(R.string.onboarding_backup_title),
|
||||
supporting = stringResource(R.string.onboarding_backup_body),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_backup_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_backup_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(OnboardingSpace.lg))
|
||||
|
||||
BenefitRow(
|
||||
|
||||
@@ -9,7 +9,6 @@ import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.CloudSync
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
@@ -30,6 +29,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarEditor
|
||||
import de.jeanlucmakiola.calendula.ui.settings.openUrl
|
||||
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||
import de.jeanlucmakiola.floret.components.OnboardingPrimaryButton
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||
|
||||
@@ -108,18 +108,13 @@ internal fun CalendarsStep(
|
||||
topSpacing = OnboardingSpace.lg,
|
||||
hero = { IconHero(Icons.Filled.CalendarMonth) },
|
||||
actions = {
|
||||
Button(
|
||||
OnboardingPrimaryButton(
|
||||
text = stringResource(R.string.onboarding_calendars_local_button),
|
||||
onClick = {
|
||||
onClearCreation()
|
||||
creating = true
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_calendars_local_button),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = { openUrl(context, DAVX5_URL) },
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
@@ -133,20 +128,9 @@ internal fun CalendarsStep(
|
||||
Text(stringResource(R.string.onboarding_calendars_skip_button))
|
||||
}
|
||||
},
|
||||
headline = stringResource(R.string.onboarding_calendars_title),
|
||||
supporting = stringResource(R.string.onboarding_calendars_body),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_calendars_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_calendars_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
if (creation is CalendarCreation.Failed) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
@@ -189,30 +173,14 @@ private fun CalendarCreated(
|
||||
topSpacing = OnboardingSpace.lg,
|
||||
hero = { IconHero(Icons.Filled.CheckCircle) },
|
||||
actions = {
|
||||
Button(
|
||||
OnboardingPrimaryButton(
|
||||
text = stringResource(R.string.onboarding_calendars_created_continue),
|
||||
onClick = onContinue,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_calendars_created_continue),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
headline = stringResource(R.string.onboarding_calendars_created_title, name),
|
||||
supporting = stringResource(R.string.onboarding_calendars_created_body),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_calendars_created_title, name),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_calendars_created_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(OnboardingSpace.lg))
|
||||
|
||||
BenefitRow(
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.floret.components.OnboardingPrimaryButton
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||
|
||||
@@ -34,28 +27,12 @@ internal fun DoneStep(
|
||||
topSpacing = OnboardingSpace.lg,
|
||||
hero = { BrandHero(HeroBadge.Done) },
|
||||
actions = {
|
||||
Button(
|
||||
OnboardingPrimaryButton(
|
||||
text = stringResource(R.string.onboarding_done_button),
|
||||
onClick = onFinished,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_done_button),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_done_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_done_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
headline = stringResource(R.string.onboarding_done_title),
|
||||
supporting = stringResource(R.string.onboarding_done_body),
|
||||
)
|
||||
}
|
||||
|
||||
+16
-39
@@ -7,24 +7,23 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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 androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarVisibilitySwitch
|
||||
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.OnboardingPrimaryButton
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
@@ -65,24 +64,17 @@ internal fun HiddenCalendarsStep(
|
||||
scrollingActions = true,
|
||||
hero = { IconHero(Icons.Filled.VisibilityOff) },
|
||||
actions = {
|
||||
Button(
|
||||
OnboardingPrimaryButton(
|
||||
text = stringResource(
|
||||
if (anyVisible) {
|
||||
R.string.onboarding_view_continue_button
|
||||
} else {
|
||||
R.string.onboarding_hidden_switch_all_button
|
||||
},
|
||||
),
|
||||
onClick = if (anyVisible) onFinished else onSwitchAllOn,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = GroupedListInset)
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (anyVisible) {
|
||||
R.string.onboarding_view_continue_button
|
||||
} else {
|
||||
R.string.onboarding_hidden_switch_all_button
|
||||
},
|
||||
),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
modifier = Modifier.padding(horizontal = GroupedListInset),
|
||||
)
|
||||
// Only while nothing is on: once something is, the step has been
|
||||
// answered and the one button left to press says so.
|
||||
if (!anyVisible) {
|
||||
@@ -129,7 +121,7 @@ internal fun HiddenCalendarsStep(
|
||||
calendars.forEachIndexed { index, calendar ->
|
||||
val off = !calendar.isVisibleInSystem
|
||||
GroupedRow(
|
||||
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
title = calendar.displayName.orUnnamed(),
|
||||
summary = calendar.accountName.takeIf { it.isNotBlank() },
|
||||
position = when {
|
||||
calendars.size == 1 -> Position.Alone
|
||||
@@ -140,8 +132,8 @@ internal fun HiddenCalendarsStep(
|
||||
dimmed = off,
|
||||
trailing = if (calendar.hasVisibilitySwitch) {
|
||||
{
|
||||
CalendarSwitch(
|
||||
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) },
|
||||
CalendarVisibilitySwitch(
|
||||
calendarName = calendar.displayName.orUnnamed(),
|
||||
enabled = !off,
|
||||
onToggle = { on -> onSetVisible(calendar.id, on) },
|
||||
)
|
||||
@@ -156,18 +148,3 @@ internal fun HiddenCalendarsStep(
|
||||
Spacer(Modifier.height(OnboardingSpace.md))
|
||||
}
|
||||
}
|
||||
|
||||
/** The per-row switch, writing the device's own `Calendars.VISIBLE` as Settings does. */
|
||||
@Composable
|
||||
private fun CalendarSwitch(
|
||||
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 },
|
||||
)
|
||||
}
|
||||
|
||||
+38
-97
@@ -10,7 +10,6 @@ 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.calendar.CalendarVisibilityReconciler
|
||||
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.data.prefs.WeekStartPref
|
||||
@@ -20,6 +19,8 @@ import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
|
||||
import de.jeanlucmakiola.calendula.domain.orderedForManager
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||
import de.jeanlucmakiola.floret.di.IoDispatcher
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -92,9 +93,8 @@ class OnboardingViewModel @Inject constructor(
|
||||
list.catch { emit(null) }.flowOn(io)
|
||||
}
|
||||
}
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
@@ -166,15 +166,6 @@ class OnboardingViewModel @Inject constructor(
|
||||
hiddenTouched,
|
||||
) { hidden, touched -> if (touched) null else hidden }
|
||||
|
||||
/** Both calendar steps' inputs: what each applies to, and whether it is answered. */
|
||||
private val calendarsFlags: Flow<CalendarsFlags> = combine(
|
||||
calendarsApplies,
|
||||
prefs.onboardingCalendarsDone,
|
||||
hiddenApplies,
|
||||
prefs.onboardingHiddenCalendarsDone,
|
||||
::CalendarsFlags,
|
||||
)
|
||||
|
||||
/**
|
||||
* The calendars the hidden-calendars step lists, so the user can judge which
|
||||
* to switch on rather than take the app's word that there are some. Live: a
|
||||
@@ -183,58 +174,44 @@ class OnboardingViewModel @Inject constructor(
|
||||
*/
|
||||
val hiddenCalendars: StateFlow<List<CalendarSource>> = calendars
|
||||
.map { it.orEmpty().orderedForManager() }
|
||||
.stateIn(
|
||||
.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = emptyList(),
|
||||
)
|
||||
|
||||
private val flags: Flow<OnboardingFlags> = combine(
|
||||
prefs.reminderOnboardingDone,
|
||||
prefs.onboardingWizardArmed,
|
||||
prefs.onboardingBackupDone,
|
||||
prefs.onboardingViewDone,
|
||||
prefs.onboardingMonthStyleDone,
|
||||
::OnboardingFlags,
|
||||
)
|
||||
|
||||
/** The closing steps' own flags, and whether the notice applies at all. */
|
||||
private val closingFlags: Flow<ClosingFlags> = combine(
|
||||
calendarPrefs.visibilityNoticePending.map { it == true },
|
||||
prefs.onboardingVisibilityDone,
|
||||
prefs.onboardingDoneShown,
|
||||
::ClosingFlags,
|
||||
)
|
||||
/** What the two calendar steps apply to (#287). */
|
||||
private val calendarStepsApply: Flow<Pair<Boolean?, Boolean?>> =
|
||||
combine(calendarsApplies, hiddenApplies, ::Pair)
|
||||
|
||||
val plan: StateFlow<OnboardingPlan?> =
|
||||
combine(
|
||||
hasPermission,
|
||||
flags,
|
||||
closingFlags,
|
||||
prefs.onboardingProgress,
|
||||
calendarPrefs.visibilityNoticePending.map { it == true },
|
||||
backupApplies,
|
||||
calendarsFlags,
|
||||
) { granted, stored, closing, backup, calendars ->
|
||||
calendarStepsApply,
|
||||
) { granted, progress, visibilityArmed, backup, (calendars, hidden) ->
|
||||
val answered = progress.answered
|
||||
granted?.let {
|
||||
onboardingPlan(
|
||||
hasPermission = it,
|
||||
remindersDone = stored.remindersDone,
|
||||
wizardArmed = stored.wizardArmed,
|
||||
backupDone = stored.backupDone,
|
||||
viewDone = stored.viewDone,
|
||||
monthStyleDone = stored.monthStyleDone,
|
||||
remindersDone = OnboardingStep.Reminders in answered,
|
||||
wizardArmed = progress.wizardArmed,
|
||||
backupDone = OnboardingStep.Backup in answered,
|
||||
viewDone = OnboardingStep.View in answered,
|
||||
monthStyleDone = OnboardingStep.MonthStyle in answered,
|
||||
backupApplies = backup,
|
||||
hiddenDone = calendars.hiddenDone,
|
||||
hiddenApplies = calendars.hiddenApplies,
|
||||
calendarsDone = calendars.done,
|
||||
calendarsApplies = calendars.applies,
|
||||
visibilityArmed = closing.visibilityArmed,
|
||||
visibilityDone = closing.visibilityDone,
|
||||
doneShown = closing.doneShown,
|
||||
hiddenDone = OnboardingStep.HiddenCalendars in answered,
|
||||
hiddenApplies = hidden,
|
||||
calendarsDone = OnboardingStep.Calendars in answered,
|
||||
calendarsApplies = calendars,
|
||||
visibilityArmed = visibilityArmed,
|
||||
visibilityDone = OnboardingStep.Visibility in answered,
|
||||
doneShown = OnboardingStep.Done in answered,
|
||||
)
|
||||
}
|
||||
}.stateIn(
|
||||
}.stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
@@ -244,9 +221,8 @@ class OnboardingViewModel @Inject constructor(
|
||||
prefs.monthViewStyle,
|
||||
prefs.weekStart,
|
||||
::ViewChoice,
|
||||
).stateIn(
|
||||
).stateWhileSubscribed(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
@@ -261,7 +237,7 @@ class OnboardingViewModel @Inject constructor(
|
||||
* to a restarted process, leaving them the reminder step alone.
|
||||
*/
|
||||
private suspend fun armWizardIfFresh() {
|
||||
if (!prefs.reminderOnboardingDone.first()) prefs.armOnboardingWizard()
|
||||
prefs.armOnboardingWizard()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -277,7 +253,7 @@ class OnboardingViewModel @Inject constructor(
|
||||
fun finishReminders(remindersEnabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.setRemindersEnabled(remindersEnabled)
|
||||
prefs.setReminderOnboardingDone()
|
||||
prefs.setOnboardingDone(OnboardingStep.Reminders)
|
||||
// Nothing else re-arms the scan: turning reminders off cancels the
|
||||
// alarm (#75).
|
||||
scanner.scan()
|
||||
@@ -291,24 +267,13 @@ class OnboardingViewModel @Inject constructor(
|
||||
* returned to, which [OnboardingPlan.canGoBack] already refuses.
|
||||
*/
|
||||
fun goBack() {
|
||||
viewModelScope.launch {
|
||||
when (plan.value?.previous) {
|
||||
OnboardingStep.HiddenCalendars -> prefs.setOnboardingHiddenCalendarsDone(false)
|
||||
OnboardingStep.Calendars -> prefs.setOnboardingCalendarsDone(false)
|
||||
OnboardingStep.Reminders -> prefs.setReminderOnboardingDone(false)
|
||||
OnboardingStep.Backup -> prefs.setOnboardingBackupDone(false)
|
||||
OnboardingStep.View -> prefs.setOnboardingViewDone(false)
|
||||
OnboardingStep.MonthStyle -> prefs.setOnboardingMonthStyleDone(false)
|
||||
OnboardingStep.Visibility -> prefs.setOnboardingVisibilityDone(false)
|
||||
OnboardingStep.Done -> prefs.setOnboardingDoneShown(false)
|
||||
OnboardingStep.Permission, null -> Unit
|
||||
}
|
||||
}
|
||||
val previous = plan.value?.previous ?: return
|
||||
viewModelScope.launch { prefs.setOnboardingDone(previous, done = false) }
|
||||
}
|
||||
|
||||
/** Close the backup step without setting anything up. */
|
||||
fun skipBackup() {
|
||||
viewModelScope.launch { prefs.setOnboardingBackupDone() }
|
||||
viewModelScope.launch { prefs.setOnboardingDone(OnboardingStep.Backup) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -340,7 +305,7 @@ class OnboardingViewModel @Inject constructor(
|
||||
/** Close the calendar step once its confirmation has been read. */
|
||||
fun finishCalendars() {
|
||||
viewModelScope.launch {
|
||||
prefs.setOnboardingCalendarsDone()
|
||||
prefs.setOnboardingDone(OnboardingStep.Calendars)
|
||||
_creation.value = null
|
||||
}
|
||||
}
|
||||
@@ -355,7 +320,7 @@ class OnboardingViewModel @Inject constructor(
|
||||
* choice, and so is going off to set up an account and coming back.
|
||||
*/
|
||||
fun skipCalendars() {
|
||||
viewModelScope.launch { prefs.setOnboardingCalendarsDone() }
|
||||
viewModelScope.launch { prefs.setOnboardingDone(OnboardingStep.Calendars) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -381,7 +346,7 @@ class OnboardingViewModel @Inject constructor(
|
||||
)
|
||||
// Immediate feedback: the first backup lands while onboarding runs.
|
||||
BackupScheduler.runNow(context)
|
||||
prefs.setOnboardingBackupDone()
|
||||
prefs.setOnboardingDone(OnboardingStep.Backup)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,11 +360,11 @@ class OnboardingViewModel @Inject constructor(
|
||||
|
||||
/** Close a picker step — the choice itself is saved as it is made. */
|
||||
fun finishView() {
|
||||
viewModelScope.launch { prefs.setOnboardingViewDone() }
|
||||
viewModelScope.launch { prefs.setOnboardingDone(OnboardingStep.View) }
|
||||
}
|
||||
|
||||
fun finishMonthStyle() {
|
||||
viewModelScope.launch { prefs.setOnboardingMonthStyleDone() }
|
||||
viewModelScope.launch { prefs.setOnboardingDone(OnboardingStep.MonthStyle) }
|
||||
}
|
||||
|
||||
/** Switch one listed calendar on or off, from the hidden-calendars step (#287). */
|
||||
@@ -436,7 +401,7 @@ class OnboardingViewModel @Inject constructor(
|
||||
/** Close the hidden-calendars step, switched on or left as they were (#287). */
|
||||
fun finishHiddenCalendars() {
|
||||
viewModelScope.launch {
|
||||
prefs.setOnboardingHiddenCalendarsDone()
|
||||
prefs.setOnboardingDone(OnboardingStep.HiddenCalendars)
|
||||
// The error belongs to the attempt, not to the step: stepping back
|
||||
// into it later must not find a refusal nothing has asked for yet.
|
||||
_hiddenWriteFailed.value = false
|
||||
@@ -451,7 +416,7 @@ class OnboardingViewModel @Inject constructor(
|
||||
/** Acknowledge the calendar-visibility notice (#75). */
|
||||
fun finishVisibility() {
|
||||
viewModelScope.launch {
|
||||
prefs.setOnboardingVisibilityDone()
|
||||
prefs.setOnboardingDone(OnboardingStep.Visibility)
|
||||
// Retiring the notice drops its step, so it waits until nothing
|
||||
// follows — the count must not fall on the way to a later screen.
|
||||
if (plan.value?.steps?.last() == OnboardingStep.Visibility) retireVisibilityNotice()
|
||||
@@ -488,36 +453,12 @@ class OnboardingViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** The stored answers the plan is derived from. */
|
||||
private data class OnboardingFlags(
|
||||
val remindersDone: Boolean,
|
||||
val wizardArmed: Boolean,
|
||||
val backupDone: Boolean,
|
||||
val viewDone: Boolean,
|
||||
val monthStyleDone: Boolean,
|
||||
)
|
||||
|
||||
/** How the calendar step's create attempt ended (#287). */
|
||||
sealed interface CalendarCreation {
|
||||
data class Created(val name: String) : CalendarCreation
|
||||
data object Failed : CalendarCreation
|
||||
}
|
||||
|
||||
/** Both calendar steps' inputs (#287). */
|
||||
private data class CalendarsFlags(
|
||||
val applies: Boolean?,
|
||||
val done: Boolean,
|
||||
val hiddenApplies: Boolean?,
|
||||
val hiddenDone: Boolean,
|
||||
)
|
||||
|
||||
/** The tail of the flow: the visibility notice and the closing screen. */
|
||||
private data class ClosingFlags(
|
||||
val visibilityArmed: Boolean,
|
||||
val visibilityDone: Boolean,
|
||||
val doneShown: Boolean,
|
||||
)
|
||||
|
||||
/** What the view step starts from, and renders its previews with. */
|
||||
data class ViewChoice(
|
||||
val defaultView: CalendarView,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||
import de.jeanlucmakiola.floret.components.OnboardingPrimaryButton
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||
|
||||
@@ -13,28 +14,20 @@ import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
|
||||
private val CALENDAR_PERMISSIONS = arrayOf(
|
||||
@@ -47,9 +40,8 @@ fun PermissionScreen(
|
||||
onGranted: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
progress: (@Composable () -> Unit)? = null,
|
||||
viewModel: PermissionViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
var denied by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// READ and WRITE are requested together (one system dialog — same
|
||||
// permission group), but only READ gates the app: declining write keeps
|
||||
@@ -58,33 +50,27 @@ fun PermissionScreen(
|
||||
contract = ActivityResultContracts.RequestMultiplePermissions(),
|
||||
) { results ->
|
||||
if (results[Manifest.permission.READ_CALENDAR] == true) {
|
||||
viewModel.onGranted()
|
||||
onGranted()
|
||||
} else {
|
||||
viewModel.onDenied()
|
||||
denied = true
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state) {
|
||||
if (state == PermissionUiState.Granted) onGranted()
|
||||
}
|
||||
|
||||
when (state) {
|
||||
is PermissionUiState.Rationale -> RationaleContent(
|
||||
onRequest = { launcher.launch(CALENDAR_PERMISSIONS) },
|
||||
progress = progress,
|
||||
modifier = modifier,
|
||||
)
|
||||
is PermissionUiState.Denied -> DeniedContent(
|
||||
if (denied) {
|
||||
DeniedContent(
|
||||
onRetry = {
|
||||
viewModel.onRetry()
|
||||
denied = false
|
||||
launcher.launch(CALENDAR_PERMISSIONS)
|
||||
},
|
||||
progress = progress,
|
||||
modifier = modifier,
|
||||
)
|
||||
is PermissionUiState.Granted -> {
|
||||
// Transient — LaunchedEffect above fires and parent replaces us.
|
||||
}
|
||||
} else {
|
||||
RationaleContent(
|
||||
onRequest = { launcher.launch(CALENDAR_PERMISSIONS) },
|
||||
progress = progress,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,37 +86,15 @@ private fun RationaleContent(
|
||||
topSpacing = OnboardingSpace.lg,
|
||||
hero = { BrandHero() },
|
||||
actions = {
|
||||
Button(
|
||||
OnboardingPrimaryButton(
|
||||
text = stringResource(R.string.permission_request_button),
|
||||
onClick = onRequest,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.permission_request_button),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Spacer(Modifier.width(OnboardingSpace.xs))
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
trailingIcon = Icons.AutoMirrored.Filled.ArrowForward,
|
||||
)
|
||||
},
|
||||
headline = stringResource(R.string.permission_rationale_title),
|
||||
supporting = stringResource(R.string.permission_rationale_body),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.permission_rationale_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.permission_rationale_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(OnboardingSpace.lg))
|
||||
|
||||
BenefitRow(
|
||||
@@ -159,7 +123,8 @@ private fun DeniedContent(
|
||||
progress = progress,
|
||||
hero = { BrandHero(HeroBadge.Denied) },
|
||||
actions = {
|
||||
Button(
|
||||
OnboardingPrimaryButton(
|
||||
text = stringResource(R.string.permission_open_settings_button),
|
||||
onClick = {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", context.packageName, null)
|
||||
@@ -167,13 +132,7 @@ private fun DeniedContent(
|
||||
}
|
||||
context.startActivity(intent)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.permission_open_settings_button),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
)
|
||||
TextButton(
|
||||
onClick = onRetry,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -181,18 +140,7 @@ private fun DeniedContent(
|
||||
Text(stringResource(R.string.permission_retry_button))
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.permission_denied_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.permission_denied_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
headline = stringResource(R.string.permission_denied_title),
|
||||
supporting = stringResource(R.string.permission_denied_body),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
sealed interface PermissionUiState {
|
||||
data object Rationale : PermissionUiState
|
||||
data object Denied : PermissionUiState
|
||||
data object Granted : PermissionUiState
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class PermissionViewModel @Inject constructor() : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow<PermissionUiState>(PermissionUiState.Rationale)
|
||||
val state: StateFlow<PermissionUiState> = _state.asStateFlow()
|
||||
|
||||
// The visibility reconcile a grant owes (#75) hangs off RootScreen, which
|
||||
// also catches grants made outside the app.
|
||||
fun onGranted() {
|
||||
_state.value = PermissionUiState.Granted
|
||||
}
|
||||
|
||||
fun onDenied() {
|
||||
_state.value = PermissionUiState.Denied
|
||||
}
|
||||
|
||||
fun onRetry() {
|
||||
_state.value = PermissionUiState.Rationale
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||
import de.jeanlucmakiola.floret.components.OnboardingPrimaryButton
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||
|
||||
@@ -14,15 +15,11 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.NotificationsActive
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
|
||||
/**
|
||||
@@ -53,7 +50,8 @@ fun ReminderStep(
|
||||
topSpacing = OnboardingSpace.lg,
|
||||
hero = { IconHero(Icons.Filled.NotificationsActive) },
|
||||
actions = {
|
||||
Button(
|
||||
OnboardingPrimaryButton(
|
||||
text = stringResource(R.string.reminder_onboarding_enable_button),
|
||||
onClick = {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
@@ -61,13 +59,7 @@ fun ReminderStep(
|
||||
onFinished(true)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.reminder_onboarding_enable_button),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
)
|
||||
TextButton(
|
||||
onClick = { onFinished(false) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -75,20 +67,9 @@ fun ReminderStep(
|
||||
Text(stringResource(R.string.reminder_onboarding_skip_button))
|
||||
}
|
||||
},
|
||||
headline = stringResource(R.string.reminder_onboarding_title),
|
||||
supporting = stringResource(R.string.reminder_onboarding_body),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.reminder_onboarding_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.reminder_onboarding_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(OnboardingSpace.lg))
|
||||
|
||||
BenefitRow(
|
||||
|
||||
@@ -19,8 +19,6 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaViewPreview
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalMultiDayCount
|
||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame
|
||||
import de.jeanlucmakiola.calendula.ui.common.icon
|
||||
import de.jeanlucmakiola.calendula.ui.common.label
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
||||
@@ -28,11 +26,12 @@ import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.ui.month.descriptionRes
|
||||
import de.jeanlucmakiola.calendula.ui.month.labelRes
|
||||
import de.jeanlucmakiola.calendula.ui.week.WeekViewPreview
|
||||
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.ViewPreviewFrame
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
|
||||
@@ -77,11 +76,10 @@ internal fun ViewStep(
|
||||
}
|
||||
}
|
||||
IMPLEMENTED_VIEWS.forEachIndexed { index, view ->
|
||||
val isSelected = view == choice.defaultView
|
||||
GroupedRow(
|
||||
title = view.label(),
|
||||
position = positionOf(index, IMPLEMENTED_VIEWS.size),
|
||||
selected = isSelected,
|
||||
checked = view == choice.defaultView,
|
||||
leading = {
|
||||
Icon(
|
||||
imageVector = view.icon,
|
||||
@@ -89,11 +87,6 @@ internal fun ViewStep(
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
trailing = if (isSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { onSelect(view) },
|
||||
)
|
||||
}
|
||||
@@ -127,18 +120,12 @@ internal fun MonthStyleStep(
|
||||
StepPreview(selected = choice.monthViewStyle) { style ->
|
||||
MonthStylePreview(style = style, weekStart = weekStart, height = PREVIEW_HEIGHT)
|
||||
}
|
||||
PickerDescription(stringResource(choice.monthViewStyle.descriptionRes))
|
||||
GroupedHint(stringResource(choice.monthViewStyle.descriptionRes), verticalPadding = 8.dp)
|
||||
options.forEachIndexed { index, style ->
|
||||
val isSelected = style == choice.monthViewStyle
|
||||
GroupedRow(
|
||||
title = stringResource(style.labelRes),
|
||||
position = positionOf(index, options.size),
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
checked = style == choice.monthViewStyle,
|
||||
onClick = { onSelect(style) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.floret.components.OnboardingPrimaryButton
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||
|
||||
@@ -38,28 +31,12 @@ internal fun VisibilityStep(
|
||||
topSpacing = OnboardingSpace.lg,
|
||||
hero = { IconHero(Icons.Filled.VisibilityOff) },
|
||||
actions = {
|
||||
Button(
|
||||
OnboardingPrimaryButton(
|
||||
text = stringResource(R.string.onboarding_visibility_button),
|
||||
onClick = onFinished,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.onboarding_visibility_button),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.calendars_visibility_notice_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.calendars_visibility_notice_message),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
headline = stringResource(R.string.calendars_visibility_notice_title),
|
||||
supporting = stringResource(R.string.calendars_visibility_notice_message),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.search
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@@ -72,7 +71,6 @@ import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
@@ -82,6 +80,7 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.SearchHit
|
||||
import de.jeanlucmakiola.calendula.domain.SearchMonth
|
||||
import de.jeanlucmakiola.calendula.domain.isDeclined
|
||||
import de.jeanlucmakiola.floret.android.hasPermission
|
||||
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||
@@ -99,7 +98,6 @@ import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
@@ -167,10 +165,7 @@ fun SearchScreen(
|
||||
pendingWrite = null
|
||||
}
|
||||
val requireWrite: (() -> Unit) -> Unit = { action ->
|
||||
val granted = ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.WRITE_CALENDAR,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
val granted = context.hasPermission(Manifest.permission.WRITE_CALENDAR)
|
||||
if (granted) {
|
||||
action()
|
||||
} else {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user