Compare commits

...
8 Commits
15 changed files with 1020 additions and 1077 deletions
@@ -36,6 +36,8 @@ 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
@@ -67,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() {
@@ -81,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.
@@ -107,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.
@@ -161,17 +144,17 @@ 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
@@ -230,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
@@ -304,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 }
}
/**
@@ -4,10 +4,14 @@ import android.content.Context
import android.content.Intent
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.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.transformLatest
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
@@ -43,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)
}
@@ -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?) },
)
@@ -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,6 +1,5 @@
package de.jeanlucmakiola.calendula.ui
import android.Manifest
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -23,13 +22,13 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
import de.jeanlucmakiola.calendula.ui.common.canReadCalendar
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingStep
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingSteps
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingViewModel
import de.jeanlucmakiola.calendula.ui.onboarding.onboardingSlideTransition
import de.jeanlucmakiola.calendula.ui.onboarding.rememberOnboardingSlideSpec
import de.jeanlucmakiola.floret.android.hasPermission
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
/** What the root is showing: a wizard step, the app, or neither yet. */
@@ -42,26 +41,12 @@ private sealed interface RootTarget {
@Composable
fun RootScreen(
requests: ExternalRequests,
modifier: Modifier = Modifier,
requestedDetailKey: LongArray? = null,
onDetailKeyConsumed: () -> Unit = {},
widgetNavRequest: WidgetNavRequest? = null,
onWidgetNavConsumed: () -> Unit = {},
requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {},
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
requestedInsertSource: de.jeanlucmakiola.calendula.ui.edit.ImportSource =
de.jeanlucmakiola.calendula.ui.edit.ImportSource.Insert,
onInsertConsumed: () -> Unit = {},
onExternalFormClosed: () -> Unit = {},
requestedEditKey: LongArray? = null,
onEditKeyConsumed: () -> Unit = {},
) {
val context = LocalContext.current
var hasPermission by remember {
mutableStateOf(
context.hasPermission(Manifest.permission.READ_CALENDAR)
)
mutableStateOf(context.canReadCalendar())
}
// A launch scan already covers the app coming up with the permission, so
@@ -72,7 +57,7 @@ fun RootScreen(
DisposableEffect(lifecycle) {
val obs = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
hasPermission = context.hasPermission(Manifest.permission.READ_CALENDAR)
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.
@@ -142,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.
@@ -10,6 +10,7 @@ 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
@@ -26,7 +27,6 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
@@ -98,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)
}
.stateWhileSubscribed(viewModelScope, todayDate)
// A transient, in-view override of the saved agenda range. Held in memory
// (not persisted), so it survives view switches and rotation within the
@@ -72,6 +72,10 @@ suspend fun CalendarRepository.deleteEvent(
/** How a [tryDeleteEvent] ended. */
enum class DeleteOutcome { Deleted, Gone, NeedsPermission, Failed }
/** Whether this install may read the calendar at all. */
fun Context.canReadCalendar(): Boolean =
hasPermission(Manifest.permission.READ_CALENDAR)
/** Whether this install may write to the calendar; v1.0 installs only hold read access. */
fun Context.canWriteCalendar(): Boolean =
hasPermission(Manifest.permission.WRITE_CALENDAR)
@@ -390,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. */
@@ -766,6 +769,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
@@ -775,8 +784,8 @@ class TimelineDragController {
liftedInstanceId = landed.event.instanceId
settledOrigin = landed.event.eventId to landed.event.start
settledAbandonedMark = abandonedTicks?.value ?: 0
settledEnd = end
drops++
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset)
}
/**
@@ -807,7 +816,7 @@ class TimelineDragController {
val edgeDay = if (edge == ResizeEdge.Top) 0 else (resizeEndMin - 1).floorDiv(MINUTES_PER_DAY).coerceAtLeast(0)
val edgeIndex = geometry.days.indexOf(span.day.plus(edgeDay, DateTimeUnit.DAY))
resizeColumn = edgeIndex.takeIf { it >= 0 }
?.let { if (geometry.isRtl) geometry.days.lastIndex - it else it }
?.let { column(it) }
resizeGrabMin = (pointerResizeMinutes() ?: 0f) -
if (edge == ResizeEdge.Top) resizeStartMin else resizeEndMin
originSlot = null
@@ -845,13 +854,7 @@ class TimelineDragController {
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
drops++
settle(landed, end = drop.end)
return drop
}
@@ -867,14 +870,13 @@ class TimelineDragController {
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,
lastIndex = days.lastIndex,
)
resizeColumn = column
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
val dayOffset = (days[dayIndex].toEpochDays() - day.toEpochDays()).toInt()
resizeColumn = held
val dayOffset = (days[column(held)].toEpochDays() - day.toEpochDays()).toInt()
resizeEdgeDay = dayOffset
return dayOffset * MINUTES_PER_DAY + (pointer.y - origin.y) / hourPx * 60f
}
@@ -887,8 +889,6 @@ class TimelineDragController {
resizeSlot = slot
val (start, end) = resizedSpan(edge, resizeStartMin, resizeEndMin, slot.toFloat(), resizeEdgeDay)
val days = geometry.days
val hourPx = geometry.hourPx
val columnPx = geometry.columnWidthPx
val origin = geometry.grid?.takeIf { it.isAttached }?.positionInRoot() ?: return
// Which column the start day sits in, possibly off either end of the view.
val startIndex = (day.toEpochDays() - days.first().toEpochDays()).toInt()
@@ -901,20 +901,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)
@@ -972,10 +959,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
@@ -1003,28 +989,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
@@ -84,7 +84,6 @@ 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
@@ -303,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(
@@ -536,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),
)
@@ -683,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
@@ -707,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
File diff suppressed because it is too large Load Diff
@@ -10,6 +10,7 @@ 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
@@ -90,16 +91,9 @@ class MonthViewModel @Inject constructor(
private val todayDate: LocalDate
get() = currentDate()
private var lastToday = todayDate
/** Today, kept current across midnight so a grid left open doesn't mark yesterday (#373). */
val today: StateFlow<LocalDate> = todaySource.today
.onEach { date ->
val old = lastToday
lastToday = date
if (old != date) followToday(old, date)
}
.stateWhileSubscribed(viewModelScope, todayDate)
val today: StateFlow<LocalDate> =
todaySource.today.stateFollowingDays(viewModelScope, todayDate, ::followToday)
private val _month = MutableStateFlow(todayDate.yearMonth)
val month: StateFlow<YearMonth> = _month
@@ -0,0 +1,57 @@
package de.jeanlucmakiola.calendula.ui.week
import androidx.compose.foundation.ScrollState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
import de.jeanlucmakiola.calendula.ui.common.TimelineResizeDrop
import kotlinx.datetime.LocalDate
/**
* What every timeline page hands down to its day columns: the shared vertical
* scroll, the drag controller, and the callbacks. One instance per screen, so
* passing it never makes a page recompose.
*/
@Stable
internal class TimelineActions(
val scrollState: ScrollState,
val dragController: TimelineDragController,
val onEventClick: (EventInstance) -> Unit,
val onOpenDay: (LocalDate) -> Unit,
val onCreateAt: (LocalDate, Int) -> Unit,
val onDrop: (TimelineDrop) -> Unit,
val onResize: (TimelineResizeDrop) -> Unit,
)
/** The callbacks are read through their latest values, so the holder outlives them. */
@Composable
internal fun rememberTimelineActions(
scrollState: ScrollState,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
): TimelineActions {
val eventClick = rememberUpdatedState(onEventClick)
val openDay = rememberUpdatedState(onOpenDay)
val createAt = rememberUpdatedState(onCreateAt)
val drop = rememberUpdatedState(onDrop)
val resize = rememberUpdatedState(onResize)
return remember(scrollState, dragController) {
TimelineActions(
scrollState = scrollState,
dragController = dragController,
onEventClick = { eventClick.value(it) },
onOpenDay = { openDay.value(it) },
onCreateAt = { date, minutes -> createAt.value(date, minutes) },
onDrop = { drop.value(it) },
onResize = { resize.value(it) },
)
}
}
@@ -39,7 +39,6 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -516,7 +515,7 @@ private fun PagedTimelineScreen(
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
onCreateAt = onCreateEvent,
onCreateDraft = onCreateDraft,
draftMinutes = draftMinutes,
modifier = Modifier
@@ -648,6 +647,16 @@ private fun WeekContent(
}
}
val actions = rememberTimelineActions(
scrollState = scrollState,
dragController = dragController,
onEventClick = openEvent,
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
Box(modifier = modifier) {
CompositionLocalProvider(LocalOpeningTimeline provides timelineScroll) {
// Loading and failure concern the provider as a whole, so they stand in
@@ -663,17 +672,7 @@ private fun WeekContent(
WeekUiState.Loading -> WeekLoading(dayCount = grid.dayCount)
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is WeekUiState.Success -> if (morph != null) {
TimelineMorphFrame(
morph = morph,
today = today,
scrollState = scrollState,
dragController = dragController,
onEventClick = openEvent,
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
TimelineMorphFrame(morph = morph, today = today, actions = actions)
} else WeekPager(
pagerState = pagerState,
grid = grid,
@@ -681,13 +680,7 @@ private fun WeekContent(
span = span,
today = today,
initialStripHeight = handoffStrip ?: s.allDayStripHeight(),
scrollState = scrollState,
dragController = dragController,
onEventClick = openEvent,
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
actions = actions,
)
}
}
@@ -708,22 +701,16 @@ private fun WeekPager(
span: (DaySpan) -> StateFlow<WeekUiState>,
today: LocalDate,
initialStripHeight: Dp,
scrollState: ScrollState,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
actions: TimelineActions,
) {
val dragController = actions.dragController
val stripHeights = remember { mutableStateMapOf<Int, Dp>() }
val allDayHeight = pagedHeight(pagerState, stripHeights, initialStripHeight)
WeekFrame(
weekStart = grid.startForPage(pagerState.currentPage),
showWeekNumber = kind.showWeekNumber,
allDayHeight = allDayHeight,
scrollState = scrollState,
dragController = dragController,
actions = actions,
) {
HorizontalPager(
state = pagerState,
@@ -756,14 +743,8 @@ private fun WeekPager(
},
headerBias = kind.headerBias,
allDayHeight = allDayHeight,
scrollState = scrollState,
dragController = dragController,
actions = actions,
active = page == pagerState.currentPage,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
}
}
@@ -792,13 +773,7 @@ private fun emptySpan(span: DaySpan, today: LocalDate): WeekUiState.Success {
private fun TimelineMorphFrame(
morph: TimelineMorph,
today: LocalDate,
scrollState: ScrollState,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
actions: TimelineActions,
) {
val fromState by morph.fromState.collectAsStateWithLifecycle()
val toState by morph.toState.collectAsStateWithLifecycle()
@@ -836,8 +811,7 @@ private fun TimelineMorphFrame(
allDayHeight = fromStrip,
animatedAllDayHeight = animatedStrip,
weekNumberAlpha = badgeAlpha,
scrollState = scrollState,
dragController = dragController,
actions = actions,
) {
TimelinePageContent(
geometry = geometry,
@@ -847,14 +821,8 @@ private fun TimelineMorphFrame(
headerBias = headerBias,
allDayHeight = fromStrip,
animatedAllDayHeight = animatedStrip,
scrollState = scrollState,
dragController = dragController,
actions = actions,
active = false,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
}
}
@@ -888,8 +856,7 @@ private fun WeekFrame(
allDayHeight: Dp,
animatedAllDayHeight: (() -> Dp)? = null,
weekNumberAlpha: () -> Float = { 1f },
scrollState: ScrollState,
dragController: TimelineDragController,
actions: TimelineActions,
pages: @Composable () -> Unit,
) {
val gutterFullHeight = remember(animatedAllDayHeight) { intArrayOf(-1) }
@@ -923,10 +890,10 @@ private fun WeekFrame(
else maxHeight
val hourHeight = zoom.scale.hourHeight(fitHeight)
HourGutter(
scrollState = scrollState,
scrollState = actions.scrollState,
hourHeight = hourHeight,
dragController = dragController,
modifier = rememberTimelinePinchZoom(scrollState, fitHeight, hourHeight, zoom),
dragController = actions.dragController,
modifier = rememberTimelinePinchZoom(actions.scrollState, fitHeight, hourHeight, zoom),
)
}
}
@@ -941,34 +908,21 @@ internal fun WeekSuccess(
state: WeekUiState.Success,
showWeekNumber: Boolean = true,
headerBias: Float = 0f,
scrollState: ScrollState,
allDayHeight: Dp,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
actions: TimelineActions,
) {
WeekFrame(
weekStart = state.weekStart,
showWeekNumber = showWeekNumber,
allDayHeight = allDayHeight,
scrollState = scrollState,
dragController = dragController,
actions = actions,
) {
WeekPage(
state = state,
headerBias = headerBias,
allDayHeight = allDayHeight,
scrollState = scrollState,
dragController = dragController,
actions = actions,
active = true,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
}
}
@@ -984,14 +938,8 @@ private fun WeekPage(
state: WeekUiState.Success,
headerBias: Float,
allDayHeight: Dp,
scrollState: ScrollState,
dragController: TimelineDragController,
actions: TimelineActions,
active: Boolean,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
) {
val geometry = remember(state.weekStart, state.days.size) {
ColumnGeometry.of(DaySpan(state.weekStart, state.days.size))
@@ -1004,14 +952,8 @@ private fun WeekPage(
bars = bars,
headerBias = headerBias,
allDayHeight = allDayHeight,
scrollState = scrollState,
dragController = dragController,
actions = actions,
active = active,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
}
@@ -1028,14 +970,8 @@ private fun TimelinePageContent(
headerBias: Float,
allDayHeight: Dp,
animatedAllDayHeight: (() -> Dp)? = null,
scrollState: ScrollState,
dragController: TimelineDragController,
actions: TimelineActions,
active: Boolean,
onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
Column(
@@ -1043,14 +979,14 @@ private fun TimelinePageContent(
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface),
) {
WeekDayHeader(geometry = geometry, today = today, bias = headerBias, onOpenDay = onOpenDay)
WeekDayHeader(geometry = geometry, today = today, bias = headerBias, onOpenDay = actions.onOpenDay)
// This strip is painted on `surface`, so an outlined chip has to
// fill with that and not with the columns' container (#230).
CompositionLocalProvider(
LocalChipGround provides MaterialTheme.colorScheme.surface,
) {
AllDayStrip(geometry = geometry, bars = bars, height = allDayHeight,
animatedHeight = animatedAllDayHeight, onEventClick = onEventClick)
animatedHeight = animatedAllDayHeight, onEventClick = actions.onEventClick)
}
}
Spacer(Modifier.height(TIMELINE_TOP_GAP))
@@ -1060,13 +996,8 @@ private fun TimelinePageContent(
timedFor = timedFor,
allDayHeight = allDayHeight,
animatedAllDayHeight = animatedAllDayHeight,
scrollState = scrollState,
dragController = dragController,
actions = actions,
active = active,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
)
}
}
@@ -1290,14 +1221,11 @@ private fun Timeline(
timedFor: (LocalDate) -> List<TimedBlock>,
allDayHeight: Dp,
animatedAllDayHeight: (() -> Dp)? = null,
scrollState: ScrollState,
dragController: TimelineDragController,
actions: TimelineActions,
active: Boolean,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
) {
val scrollState = actions.scrollState
val dragController = actions.dragController
val fullViewportHeight = remember(geometry) { intArrayOf(-1) }
val dark = LocalFloretDarkTheme.current
val zoom = LocalTimelineZoom.current
@@ -1390,12 +1318,7 @@ private fun Timeline(
columnWidth = columnWidth,
visibleTop = visibleTop,
viewportHeight = visibleViewport,
dragController = dragController,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
onResize = onResize,
scrollState = scrollState,
actions = actions,
modifier = Modifier.fillMaxHeight(),
)
}
@@ -1413,14 +1336,11 @@ private fun DayColumnCard(
columnWidth: Dp,
visibleTop: () -> Float,
viewportHeight: () -> Float,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
onResize: (TimelineResizeDrop) -> Unit,
scrollState: ScrollState,
actions: TimelineActions,
modifier: Modifier = Modifier,
) {
val scrollState = actions.scrollState
val dragController = actions.dragController
val density = LocalDensity.current
val hourPx = with(density) { hourHeight.toPx() }
val morphing = LocalViewMorph.current != null
@@ -1438,13 +1358,13 @@ private fun DayColumnCard(
}
// The tap detector outlives the composition that installed it — a pinch or a
// Settings change moves the hour height without restarting it — so it reads
// the height and the callback through state handles instead of capturing
// them, or taps land on the scale the column had before the zoom (#148).
// the height through a state handle instead of capturing it, or taps land on
// the scale the column had before the zoom (#148). The actions read their
// callbacks' latest values themselves.
val currentHourPx = rememberUpdatedState(hourPx)
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
val edges = rememberOutOfViewEdges(blocks, scrollState, hourPx)
val currentEdges = rememberUpdatedState(edges)
val draftSurface = rememberDraftSurface(date, edges, hourPx, dragController, onDrop)
val draftSurface = rememberDraftSurface(date, edges, hourPx, dragController, actions.onDrop)
Card(
// Plain rectangular columns — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges.
@@ -1478,7 +1398,7 @@ private fun DayColumnCard(
return@detectTapGestures
}
if (currentEdges.value.tap(offset.y)) return@detectTapGestures
currentOnCreateAt.value(
actions.onCreateAt(
date,
tappedMinuteOfDay(offset.y, currentHourPx.value),
)
@@ -1538,9 +1458,9 @@ private fun DayColumnCard(
cuts = cuts,
topInset = topInset,
dragController = dragController,
onClick = { onEventClick(block.event) },
onDrop = onDrop,
onResize = onResize,
onClick = { actions.onEventClick(block.event) },
onDrop = actions.onDrop,
onResize = actions.onResize,
modifier = Modifier
.laneSlot(place)
.offset(y = place.y + topInset)
@@ -10,6 +10,7 @@ import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.data.time.TodaySource
import de.jeanlucmakiola.calendula.data.time.ZoneSource
import de.jeanlucmakiola.calendula.data.time.currentDate
import de.jeanlucmakiola.calendula.data.time.stateFollowingDays
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.displayPriority
@@ -32,7 +33,6 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
@@ -124,16 +124,9 @@ abstract class PagedTimelineViewModel(
val rule: StateFlow<(LocalDate) -> PageGrid> =
gridRule.stateIn(viewModelScope, SharingStarted.Eagerly, initialRule)
private var lastToday = todayDate
/** Today, kept current across midnight so a page left open doesn't mark yesterday (#373). */
private val today: StateFlow<LocalDate> = todaySource.today
.onEach { date ->
val old = lastToday
lastToday = date
if (old != date) followToday(old, date)
}
.stateWhileSubscribed(viewModelScope, todayDate)
private val today: StateFlow<LocalDate> =
todaySource.today.stateFollowingDays(viewModelScope, todayDate, ::followToday)
private val position: StateFlow<PagerPosition> =
combine(_anchor, rule) { anchor, rule -> positionOf(anchor, rule) }
@@ -52,14 +52,16 @@ internal fun WeekViewPreview(
state = state,
showWeekNumber = dayCount == 7,
headerBias = if (dayCount == 1) TimelineKind.Day.headerBias else 0f,
scrollState = scrollState,
allDayHeight = state.allDayStripHeight(),
dragController = rememberTimelineDragController(),
onEventClick = {},
onOpenDay = {},
onCreateAt = { _, _ -> },
onDrop = {},
onResize = {},
actions = rememberTimelineActions(
scrollState = scrollState,
dragController = rememberTimelineDragController(),
onEventClick = {},
onOpenDay = {},
onCreateAt = { _, _ -> },
onDrop = {},
onResize = {},
),
)
}
}