From efc36f1bff7b51d4a0be58a5644d7c019ff9580d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 16 Oct 2026 14:26:53 +0200 Subject: [PATCH] Restructure the editor, timeline and calendar host (#417) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 7 of the simplification review: structure changes in the three biggest UI files. No intended visible change, except one fix noted under the host. **Editor** (EventEditScreen) - Pass-through wrappers removed: InlineField → floret InlineTextField, SaveScopeDialog → RecurringScopeDialog, REMINDER_QUICK_PICKS → REMINDER_PRESETS, reminderLabel/reminderLeadText → reminderLeadTimeLabel, currentDetailLocale → currentLocale. - One PickerCardBody draws the time zone, recurrence, visibility and colour cards. - The seven saveable showXPicker booleans are one saveable `EditSheet?` slot, so two sheets can't be open at once. EventEditContent goes from ~770 to ~250 lines; each card section and the sheet host are their own composables. - Not done: GroupedSurface on the grouped form cards, since it adds a visible press-morph. **Timeline** (WeekScreen, TimelineDrag) - A remembered TimelineActions holder (scroll state, drag controller, five callbacks) replaces the seven values threaded through nine composables. Callbacks go through rememberUpdatedState, so the holder stays stable and pages don't recompose. - TimelineDragController shares pieces(), settle(), column() (the RTL flip, was 5 copies) and snapToSlot() between move and resize. No extra per-frame work. **Calendar host** (CalendarHost, RootScreen, MainActivity, view models) - SlideOverlay replaces seven copies of the overlay enter/exit block; rememberHeld replaces the hand-paired held keys; one saveable CreateRequest replaces three create fields, and the widget create path calls openCreate. - An ExternalRequests holder built in MainActivity replaces 13 parameters declared twice in RootScreen and CalendarHost. - Month, week and agenda view models share one rollover-following today flow. CalendarHost keeps its own, which survives recreation. - Fix: after rotation or process death with the detail or edit overlay open, closing it slid out a blank screen, because the held key was only set by in-app opens. It now keeps its content through the slide-out, as ARCHITECTURE.md describes. Not yet checked on a device. Worth a look before release: open/close of detail, edit and create from every entry point (tap, notification, widget, QS tile, ACTION_INSERT/EDIT, .ics), rotation with an overlay open, every editor picker incl. rotation, and timeline move/resize/drag-to-create incl. RTL and after pinch-zoom. Co-authored-by: Jean-Luc Makiola Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/417 --- .../jeanlucmakiola/calendula/MainActivity.kt | 73 +- .../calendula/data/time/TodaySource.kt | 22 + .../calendula/ui/CalendarHost.kt | 285 ++-- .../calendula/ui/ExternalRequests.kt | 42 + .../jeanlucmakiola/calendula/ui/RootScreen.kt | 36 +- .../calendula/ui/agenda/AgendaViewModel.kt | 15 +- .../calendula/ui/common/EventActions.kt | 4 + .../calendula/ui/common/TimelineDrag.kt | 96 +- .../calendula/ui/detail/EventDetailScreen.kt | 14 +- .../calendula/ui/edit/EventEditScreen.kt | 1236 +++++++++-------- .../calendula/ui/month/MonthViewModel.kt | 12 +- .../calendula/ui/week/TimelineActions.kt | 57 + .../calendula/ui/week/WeekScreen.kt | 176 +-- .../calendula/ui/week/WeekViewModel.kt | 13 +- .../calendula/ui/week/WeekViewPreview.kt | 16 +- 15 files changed, 1020 insertions(+), 1077 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/ExternalRequests.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/week/TimelineActions.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt index 3d0e399..d0d4fe5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt @@ -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(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(null) - - // An .ics file opened/shared into the app (ACTION_VIEW/SEND). Consumed once - // by CalendarHost's import flow. - private var requestedImportUri by mutableStateOf(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(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/): - // opens the occurrence in the edit form. Same occurrence-key shape as the - // detail channel; consumed once by CalendarHost. - private var requestedEditKey by mutableStateOf(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 } } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/time/TodaySource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/time/TodaySource.kt index 0653c41..83f03c3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/time/TodaySource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/time/TodaySource.kt @@ -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.stateFollowingDays( + scope: CoroutineScope, + initial: LocalDate, + onRollover: (old: LocalDate, new: LocalDate) -> Unit, +): StateFlow { + var last = initial + return onEach { date -> + val old = last + last = date + if (old != date) onRollover(old, date) + }.stateWhileSubscribed(scope, initial) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index b16611f..7894543 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -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(null) } - var heldKey by remember { mutableStateOf(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(null) } - var heldCreateIso by remember { mutableStateOf(null) } - var createStartMinutes by rememberSaveable { mutableStateOf(null) } - var heldCreateMinutes by remember { mutableStateOf(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(null) } - var heldCreateDraftMillis by remember { mutableStateOf(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(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(null) } - var heldEditKey by remember { mutableStateOf(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, 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 rememberHeld(live: T?): T? { + val held = remember { HeldValue() } + if (live != null) held.value = live + return live ?: held.value +} + +private class HeldValue { + 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>( + 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?) }, +) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/ExternalRequests.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/ExternalRequests.kt new file mode 100644 index 0000000..6402696 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/ExternalRequests.kt @@ -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(null) + + /** A home-screen widget, shortcut or tile navigation (open a date, an event, a view, or create). */ + var nav by mutableStateOf(null) + + /** An .ics file opened or shared into the app (ACTION_VIEW/SEND). */ + var importUri by mutableStateOf(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(null) + + /** An external "edit this event" (ACTION_EDIT), in the detail key's shape. */ + var editKey by mutableStateOf(null) +} + +/** A prefilled create form from an external launch, with the source it came from. */ +data class InsertRequest(val form: EventForm, val source: ImportSource) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt index c29f0ab..033377c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt @@ -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. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt index aee15c6..06ddf5d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt @@ -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 = 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 = + 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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActions.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActions.kt index b9bef70..d4d461b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActions.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActions.kt @@ -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) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt index 9a60592..5d31e29 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt @@ -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, dayIndex: Int, origin: Offset): List { + 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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index 6b8ca58..a91eefd 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 356981f..914f0ce 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -133,7 +133,6 @@ import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf import de.jeanlucmakiola.calendula.domain.EventFormProblem import de.jeanlucmakiola.calendula.domain.RecurrenceEnd import de.jeanlucmakiola.calendula.domain.RecurrenceFreq -import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.SimpleRecurrence import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence import de.jeanlucmakiola.calendula.domain.toRRule @@ -450,10 +449,13 @@ fun EventEditScreen( } if (state?.saveState == SaveUiState.AwaitingScope) { - SaveScopeDialog( - recurrenceChanged = state?.recurrenceChanged == true, + // A changed rule rules out the single occurrence — an exception row + // can't carry its own rule. + RecurringScopeDialog( + title = stringResource(R.string.event_edit_recurring_title), onSelect = viewModel::saveWithScope, onDismiss = viewModel::consumeSaveResult, + allowOccurrence = state?.recurrenceChanged != true, ) } @@ -654,28 +656,47 @@ private fun SaveConflictDialog( } } -/** - * Scope choice when saving a dirty recurring event: one tap writes just the - * tapped occurrence, it and everything after (series split), or the whole - * series. A changed recurrence rule rules out the single occurrence — an - * exception row can't carry its own rule. - */ -@Composable -private fun SaveScopeDialog( - recurrenceChanged: Boolean, - onSelect: (RecurringWriteScope) -> Unit, - onDismiss: () -> Unit, -) { - RecurringScopeDialog( - title = stringResource(R.string.event_edit_recurring_title), - onSelect = onSelect, - onDismiss = onDismiss, - allowOccurrence = !recurrenceChanged, - ) -} - private enum class PickerTarget { StartDate, StartTime, EndDate, EndTime } +/** A picker card's value over its label, with the drop-down arrow when it opens a picker. */ +@Composable +private fun PickerCardBody( + title: String, + subtitle: String, + showArrow: Boolean = true, + extra: @Composable ColumnScope.() -> Unit = {}, +) = PickerCardBody(AnnotatedString(title), subtitle, showArrow, extra) + +@Composable +private fun PickerCardBody( + title: AnnotatedString, + subtitle: String, + showArrow: Boolean = true, + extra: @Composable ColumnScope.() -> Unit = {}, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = title, style = MaterialTheme.typography.titleMedium) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + extra() + } + if (showArrow) { + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + /** * Wrapper for an optional form section: fields added via "more fields" * spring open with the expressive spatial spec instead of popping in. @@ -695,6 +716,9 @@ private fun OptionalFormSection( } } +/** The one full-screen picker the form has open, if any. */ +private enum class EditSheet { Calendar, Reminder, Recurrence, Visibility, Color, Timezone, Field } + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun EventEditContent( @@ -712,18 +736,8 @@ private fun EventEditContent( // they're locked here; everything else (reminders, location, notes) is the // user's to edit. val locked = state.isManaged - // Read in the form's own window, not the picker's: the focused field lives - // here, so this is the controller that can put its keyboard away. - val focusManager = LocalFocusManager.current - val keyboardController = LocalSoftwareKeyboardController.current var picker by remember { mutableStateOf(null) } - var showCalendarPicker by rememberSaveable { mutableStateOf(false) } - var showReminderPicker by rememberSaveable { mutableStateOf(false) } - var showRecurrencePicker by rememberSaveable { mutableStateOf(false) } - var showVisibilityPicker by rememberSaveable { mutableStateOf(false) } - var showColorPicker by rememberSaveable { mutableStateOf(false) } - var showTimezonePicker by rememberSaveable { mutableStateOf(false) } - var showFieldPicker by rememberSaveable { mutableStateOf(false) } + var sheet by rememberSaveable { mutableStateOf(null) } // Pick a guest from contacts: a one-shot system picker returning the chosen // email row (email + display name). No READ_CONTACTS — the result Intent @@ -764,17 +778,6 @@ private fun EventEditContent( ?: MaterialTheme.colorScheme.primary val gap = 12.dp - // Issue #10: on a fresh create form (setting on, nothing typed yet) focus the - // title and raise the keyboard so the user can type straight away. Keyed on - // Unit so it runs once per open — not on every keystroke — and guarded so - // editing or an imported/prefilled form never grabs focus. - val titleFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - if (state.autofocusTitle && !state.isEditing && form.title.isBlank()) { - runCatching { titleFocusRequester.requestFocus() } - } - } - // Per-event colour applicability for the resolved calendar: // - palette calendars (Google, …) and local calendars always support it; // - synced calendars with no palette only when the user opted in, and even @@ -794,136 +797,22 @@ private fun EventEditContent( .verticalScroll(rememberScrollState()) .padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp), ) { - // Title: borderless headline, mirroring the detail screen's title + - // accent bar instead of a boxed Material text field. Multi-line so long - // titles wrap instead of scrolling off one line (#33); newlines are - // stripped in setTitle, so this stays a single logical line — the Enter - // key just has no effect. - InlineField( - value = form.title, - onValueChange = viewModel::setTitle, - placeholder = stringResource(R.string.event_edit_title_hint), - textStyle = MaterialTheme.typography.headlineMedium - .copy(fontWeight = FontWeight.SemiBold), - singleLine = false, - enabled = !locked, - modifier = Modifier - .fillMaxWidth() - // Ease the height change as the title wraps to another line - // instead of letting the accent bar + cards below jump. - .animateContentSizeMotion() - .padding(vertical = 4.dp) - .focusRequester(titleFocusRequester), + TitleSection( + state = state, + onTitleChange = viewModel::setTitle, + accent = accent, + calendarName = selectedCalendar?.displayName, ) - FieldLengthCounter( - length = form.title.length, - max = MAX_SINGLE_LINE_FIELD, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(10.dp)) - Box( - modifier = Modifier - .width(48.dp) - .height(3.dp) - .background(accent, RoundedCornerShape(2.dp)), - ) - if (locked) { - Spacer(Modifier.height(10.dp)) - Text( - text = stringResource( - R.string.event_edit_managed_hint, - selectedCalendar?.displayName - ?.orUnnamed() - .orEmpty(), - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } Spacer(Modifier.height(20.dp)) - // "When" card: the icon centres on the all-day row (header); the - // start/end rows continue below in the same text column. - IconInfoCard( - icon = Icons.Default.Schedule, - iconContentDescription = null, - header = { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = stringResource(R.string.event_detail_all_day), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.weight(1f), - ) - Switch( - checked = form.isAllDay, - onCheckedChange = viewModel::setAllDay, - enabled = !locked, - ) - } - }, - ) { - Spacer(Modifier.height(4.dp)) - val formZone = remember(form.timezone, deviceZone) { form.resolvedZone(deviceZone) } - val startAt = remember(form.start, formZone) { form.startInstant(formZone) } - val endAt = remember(form.end, formZone) { form.endInstant(formZone) } - ScheduleRow( - label = stringResource(R.string.event_edit_starts), - dateTime = form.start.dateTime, - instant = startAt, - zone = formZone, - isAllDay = form.isAllDay, - locale = locale, - onDateClick = { picker = PickerTarget.StartDate }, - onTimeClick = { picker = PickerTarget.StartTime }, - enabled = !locked, - ) - ScheduleRow( - label = stringResource(R.string.event_edit_ends), - dateTime = form.end.dateTime, - instant = endAt, - zone = formZone, - isAllDay = form.isAllDay, - locale = locale, - onDateClick = { picker = PickerTarget.EndDate }, - onTimeClick = { picker = PickerTarget.EndTime }, - isError = EventFormProblem.EndBeforeStart in state.problems, - enabled = !locked, - ) - if (EventFormProblem.EndBeforeStart in state.problems) { - Spacer(Modifier.height(2.dp)) - Text( - text = stringResource(R.string.event_edit_error_end_before_start), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - // The rows above edit a pinned event in *its own* zone, which is the - // time it was set at — but that says nothing about when it lands for - // whoever is reading it. Spell the local equivalent out rather than - // leaving the user to do the offset arithmetic. Absent unless the - // event is pinned somewhere other than here. - // Keyed rather than recomputed: this sits in the same Column as the - // title field, so it would otherwise re-parse the zone on every - // keystroke. - val pinnedAway = remember(form.timezone, form.isAllDay, deviceZone) { - form.isPinnedAwayFrom(deviceZone) - } - if (pinnedAway) { - Spacer(Modifier.height(2.dp)) - Text( - text = stringResource( - R.string.event_edit_timezone_local_time, - formatTimeRange(startAt, endAt, deviceZone, locale), - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } + WhenCard( + state = state, + deviceZone = deviceZone, + locale = locale, + onAllDayChange = viewModel::setAllDay, + onPick = { picker = it }, + ) Spacer(Modifier.height(gap)) @@ -931,151 +820,36 @@ private fun EventEditContent( // different one while editing *moves* the event (copy+delete on save, // since CALENDAR_ID can't be updated in place). Managed special-dates // events stay locked: the contact sync owns their calendar. - IconInfoCard( - icon = Icons.Default.CalendarMonth, - iconContentDescription = stringResource(R.string.event_detail_calendar), - iconTint = accent, - onClick = { showCalendarPicker = true } + CalendarCard( + calendar = selectedCalendar, + accent = accent, + onClick = { sheet = EditSheet.Calendar } .takeIf { state.calendars.isNotEmpty() && !state.isManaged }, - ) { - Text( - text = selectedCalendar?.displayName - ?.orUnnamed() - ?: stringResource(R.string.event_edit_error_no_calendar), - style = MaterialTheme.typography.titleMedium, - color = if (selectedCalendar == null) { - MaterialTheme.colorScheme.error - } else { - Color.Unspecified - }, - ) - selectedCalendar?.accountName?.takeIf { it.isNotBlank() }?.let { account -> - Text( - text = account, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } + ) // Optional sections: which ones render by default is a setting; the // rest unfold behind the "more fields" button below. OptionalFormSection(visible = EventFormField.Timezone in state.visibleFields) { Spacer(Modifier.height(gap)) - IconInfoCard( - icon = Icons.Default.Public, - iconContentDescription = null, - onClick = { showTimezonePicker = true }, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - // Resolved at the event's own start, not at "now": the - // abbreviation and offset shown ("CEST · GMT+02:00") must be - // the ones that apply when the event actually happens. - val pinned = remember(form.timezone, form.start, locale) { - form.timezone - ?.let { id -> runCatching { id to TimeZone.of(id) }.getOrNull() } - ?.let { (id, zone) -> - timeZoneOptionOf( - id, - locale, - at = form.startInstant(zone).toJavaInstant(), - regionOf = ::icuTimeZoneRegion, - ) - } - } - Column(modifier = Modifier.weight(1f)) { - Text( - text = pinned?.label - ?: stringResource(R.string.event_edit_timezone_device), - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = pinned?.let { zoneDescriptor(it) } - ?: stringResource(R.string.event_edit_timezone_device_summary), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } + TimezoneCard(form = form, locale = locale, onClick = { sheet = EditSheet.Timezone }) } OptionalFormSection(visible = EventFormField.Location in state.visibleFields) { Spacer(Modifier.height(gap)) - IconInfoCard( - icon = Icons.Default.Place, - iconContentDescription = stringResource(R.string.event_detail_location), - iconAlignment = Alignment.Top, - iconTopPadding = ((firstLineHeight() - 24.dp) / 2).coerceAtLeast(0.dp), - ) { - Row(verticalAlignment = Alignment.Top) { - Column(modifier = Modifier.weight(1f)) { - InlineField( - value = form.location, - onValueChange = viewModel::setLocation, - placeholder = stringResource(R.string.event_detail_location), - // A location is as often a meeting URL as an address, - // and "Zoom.us/j/123" reads wrong (#146). - capitalization = KeyboardCapitalization.None, - // Multi-line so a long address or meeting URL wraps and - // the card grows instead of scrolling off one line - // (#273). The field holds no newlines of its own, so - // the IME's action key closes the keyboard rather than - // inserting a break setLocation would only fold away. - singleLine = false, - imeAction = ImeAction.Done, - modifier = Modifier - .fillMaxWidth() - .animateContentSizeMotion() - .padding(vertical = 4.dp), - ) - FieldLengthCounter( - length = form.location.length, - max = MAX_SINGLE_LINE_FIELD, + LocationCard( + location = form.location, + onLocationChange = viewModel::setLocation, + onPickContact = { + runCatching { + pickContactAddress.launch( + Intent( + Intent.ACTION_PICK, + ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_URI, + ), ) } - // The box is exactly one text line tall, so the button - // centres on the first line without making a single-line - // card taller than its text; requiredSize keeps the - // button's own 40dp ripple. - Box( - modifier = Modifier.height(firstLineHeight()), - contentAlignment = Alignment.Center, - ) { - IconButton( - onClick = { - runCatching { - pickContactAddress.launch( - Intent( - Intent.ACTION_PICK, - ContactsContract.CommonDataKinds.StructuredPostal - .CONTENT_URI, - ), - ) - } - }, - modifier = Modifier.requiredSize(40.dp), - ) { - Icon( - imageVector = Icons.Default.Contacts, - contentDescription = stringResource( - R.string.event_edit_location_from_contacts, - ), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(22.dp), - ) - } - } - } - } + }, + ) } OptionalFormSection(visible = EventFormField.Description in state.visibleFields) { @@ -1086,7 +860,7 @@ private fun EventEditContent( iconAlignment = Alignment.Top, iconTopPadding = ((firstLineHeight() - 24.dp) / 2).coerceAtLeast(0.dp), ) { - InlineField( + InlineTextField( value = form.description, onValueChange = viewModel::setDescription, placeholder = stringResource(R.string.event_detail_description), @@ -1098,92 +872,25 @@ private fun EventEditContent( OptionalFormSection(visible = EventFormField.Reminders in state.visibleFields) { Spacer(Modifier.height(gap)) - // A grouped list like guests: each reminder is its own tonal card - // with connected corners, the last row being "Add reminder". - val reminders = form.reminders - val rows = reminders.size + 1 - reminders.forEachIndexed { index, minutes -> - GroupedItemCard( - position = positionOf(index, rows), - icon = Icons.Default.Notifications, - title = reminderLabel(minutes), - removeContentDescription = stringResource(R.string.event_edit_remove_reminder), - onRemove = { viewModel.removeReminder(minutes) }, - ) - } - AddActionCard( - label = stringResource(R.string.event_edit_add_reminder), - position = positionOf(reminders.size, rows), - onClick = { showReminderPicker = true }, + RemindersSection( + reminders = form.reminders, + onRemove = viewModel::removeReminder, + onAdd = { sheet = EditSheet.Reminder }, ) } OptionalFormSection(visible = EventFormField.Recurrence in state.visibleFields) { Spacer(Modifier.height(gap)) - IconInfoCard( - icon = Icons.Default.Repeat, - iconContentDescription = null, - onClick = if (locked) null else ({ showRecurrencePicker = true }), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = form.rrule?.let { recurrenceText(it, locale) } - ?: AnnotatedString(stringResource(R.string.event_edit_recurrence_none)), - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = stringResource(R.string.event_detail_recurrence), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - if (EventFormProblem.RecurrenceEndsBeforeStart in state.problems) { - Spacer(Modifier.height(2.dp)) - Text( - text = stringResource(R.string.event_edit_error_recurrence_ends_before_start), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - } + RecurrenceCard( + state = state, + locale = locale, + onClick = if (locked) null else ({ sheet = EditSheet.Recurrence }), + ) } OptionalFormSection(visible = EventFormField.Availability in state.visibleFields) { Spacer(Modifier.height(gap)) - IconInfoCard( - icon = Icons.Default.EventAvailable, - iconContentDescription = null, - header = { - Text( - text = stringResource(R.string.event_edit_availability), - style = MaterialTheme.typography.titleMedium, - ) - }, - ) { - Spacer(Modifier.height(8.dp)) - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - SegmentedButton( - selected = form.availability == Availability.Busy, - onClick = { viewModel.setAvailability(Availability.Busy) }, - shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), - ) { Text(stringResource(R.string.event_availability_busy)) } - SegmentedButton( - selected = form.availability == Availability.Free, - onClick = { viewModel.setAvailability(Availability.Free) }, - shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), - ) { Text(stringResource(R.string.event_availability_free)) } - } - } + AvailabilityCard(availability = form.availability, onSelect = viewModel::setAvailability) } OptionalFormSection(visible = EventFormField.Visibility in state.visibleFields) { @@ -1191,113 +898,32 @@ private fun EventEditContent( IconInfoCard( icon = Icons.Default.Lock, iconContentDescription = null, - onClick = { showVisibilityPicker = true }, + onClick = { sheet = EditSheet.Visibility }, ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(accessLevelLabel(form.accessLevel)), - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = stringResource(R.string.event_edit_visibility), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + PickerCardBody( + title = stringResource(accessLevelLabel(form.accessLevel)), + subtitle = stringResource(R.string.event_edit_visibility), + ) } } OptionalFormSection(visible = EventFormField.Color in state.visibleFields) { Spacer(Modifier.height(gap)) - // The swatch the event will paint with: its own colour, else the - // calendar's. The Palette icon takes that colour as a preview. - val swatch = form.color ?: selectedCalendar?.color - IconInfoCard( - icon = Icons.Default.Palette, - iconContentDescription = stringResource(R.string.event_edit_color), - iconTint = if (colorSupported && swatch != null) { - eventFill(swatch, dark, soften) - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - onClick = { showColorPicker = true }.takeIf { colorSupported }, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource( - when { - !colorSupported -> R.string.event_edit_color_unsupported - form.color != null -> R.string.event_edit_color_custom - else -> R.string.event_edit_color_default - }, - ), - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = stringResource( - if (colorSupported) { - R.string.event_edit_color - } else { - R.string.event_edit_color_unsupported_hint - }, - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - if (colorSyncRisk) { - Spacer(Modifier.height(2.dp)) - Text( - text = stringResource(R.string.event_edit_color_sync_warning), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - if (colorSupported) { - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } + ColorCard( + color = form.color, + calendarColor = selectedCalendar?.color, + supported = colorSupported, + syncRisk = colorSyncRisk, + onClick = { sheet = EditSheet.Color }.takeIf { colorSupported }, + ) } OptionalFormSection(visible = EventFormField.Attendees in state.visibleFields) { Spacer(Modifier.height(gap)) - // Guests are an inline grouped list: each is a tonal card with a - // role toggle + remove, and the trailing card is an inline email - // field — type an address, press Done, it commits. Below sits an - // honest note — Calendula never sends invitations, so whether a - // guest is notified is the calendar backend's call. - val guests = form.attendees - val rows = guests.size + 1 - guests.forEachIndexed { index, attendee -> - GuestCard( - attendee = attendee, - position = positionOf(index, rows), - onToggleOptional = { viewModel.setAttendeeOptional(attendee, it) }, - onRemove = { viewModel.removeAttendee(attendee) }, - ) - } - AddGuestInlineCard( - position = positionOf(guests.size, rows), - onAdd = { viewModel.addAttendee(it) }, + GuestsSection( + guests = form.attendees, + isLocalCalendar = isLocalCalendar, + viewModel = viewModel, onPickContact = { runCatching { pickContact.launch( @@ -1309,25 +935,12 @@ private fun EventEditContent( } }, ) - Spacer(Modifier.height(8.dp)) - Text( - text = stringResource( - if (isLocalCalendar) { - R.string.event_edit_attendees_note_local - } else { - R.string.event_edit_attendees_note_synced - }, - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 4.dp), - ) } OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) { Spacer(Modifier.height(20.dp)) TextButton( - onClick = { showFieldPicker = true }, + onClick = { sheet = EditSheet.Field }, modifier = Modifier.align(Alignment.CenterHorizontally), ) { Icon( @@ -1365,112 +978,570 @@ private fun EventEditContent( null -> Unit } - // A full-screen picker is a change of place, so the keyboard shouldn't - // follow it. The field keeps its text; only focus and the IME go. - LaunchedEffect(showCalendarPicker) { - if (showCalendarPicker) { - focusManager.clearFocus(force = true) - keyboardController?.hide() - } - } - - if (showCalendarPicker) { - CalendarPicker( - calendars = state.calendars, - selectedId = form.calendarId, - onSelect = { - viewModel.setCalendar(it) - showCalendarPicker = false - }, - // Close the picker first: it is a Compose Dialog in its own window, - // always above the activity's content, so the manager would - // otherwise open behind it and the tap would look dead. - onManageCalendars = onManageCalendars?.let { openManager -> - { - showCalendarPicker = false - openManager() - } - }, - onDismiss = { showCalendarPicker = false }, + sheet?.let { open -> + EditSheetHost( + sheet = open, + state = state, + viewModel = viewModel, + firstDayOfWeek = firstDayOfWeek, + colorSyncRisk = colorSyncRisk, + onManageCalendars = onManageCalendars, + onClose = { sheet = null }, ) } +} - if (showReminderPicker) { - ReminderPickerDialog( +/** + * The borderless title over the accent bar, mirroring the detail screen's title + * instead of a boxed Material text field. Locked events say which calendar owns + * them. + */ +@Composable +private fun TitleSection( + state: EventEditUiState, + onTitleChange: (String) -> Unit, + accent: Color, + calendarName: String?, +) { + val form = state.form + // Issue #10: on a fresh create form (setting on, nothing typed yet) focus the + // title and raise the keyboard so the user can type straight away. Keyed on + // Unit so it runs once per open — not on every keystroke — and guarded so + // editing or an imported/prefilled form never grabs focus. + val titleFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + if (state.autofocusTitle && !state.isEditing && form.title.isBlank()) { + runCatching { titleFocusRequester.requestFocus() } + } + } + // Multi-line so long titles wrap instead of scrolling off one line (#33); + // newlines are stripped in setTitle, so this stays a single logical line — + // the Enter key just has no effect. + InlineTextField( + value = form.title, + onValueChange = onTitleChange, + placeholder = stringResource(R.string.event_edit_title_hint), + textStyle = MaterialTheme.typography.headlineMedium + .copy(fontWeight = FontWeight.SemiBold), + singleLine = false, + enabled = !state.isManaged, + modifier = Modifier + .fillMaxWidth() + // Ease the height change as the title wraps to another line + // instead of letting the accent bar + cards below jump. + .animateContentSizeMotion() + .padding(vertical = 4.dp) + .focusRequester(titleFocusRequester), + ) + FieldLengthCounter( + length = form.title.length, + max = MAX_SINGLE_LINE_FIELD, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(10.dp)) + Box( + modifier = Modifier + .width(48.dp) + .height(3.dp) + .background(accent, RoundedCornerShape(2.dp)), + ) + if (state.isManaged) { + Spacer(Modifier.height(10.dp)) + Text( + text = stringResource( + R.string.event_edit_managed_hint, + calendarName?.orUnnamed().orEmpty(), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** + * "When" card: the icon centres on the all-day row (header); the start/end rows + * continue below in the same text column. + */ +@Composable +private fun WhenCard( + state: EventEditUiState, + deviceZone: TimeZone, + locale: Locale, + onAllDayChange: (Boolean) -> Unit, + onPick: (PickerTarget) -> Unit, +) { + val form = state.form + val locked = state.isManaged + IconInfoCard( + icon = Icons.Default.Schedule, + iconContentDescription = null, + header = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.event_detail_all_day), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + Switch( + checked = form.isAllDay, + onCheckedChange = onAllDayChange, + enabled = !locked, + ) + } + }, + ) { + Spacer(Modifier.height(4.dp)) + val formZone = remember(form.timezone, deviceZone) { form.resolvedZone(deviceZone) } + val startAt = remember(form.start, formZone) { form.startInstant(formZone) } + val endAt = remember(form.end, formZone) { form.endInstant(formZone) } + ScheduleRow( + label = stringResource(R.string.event_edit_starts), + dateTime = form.start.dateTime, + instant = startAt, + zone = formZone, + isAllDay = form.isAllDay, + locale = locale, + onDateClick = { onPick(PickerTarget.StartDate) }, + onTimeClick = { onPick(PickerTarget.StartTime) }, + enabled = !locked, + ) + ScheduleRow( + label = stringResource(R.string.event_edit_ends), + dateTime = form.end.dateTime, + instant = endAt, + zone = formZone, + isAllDay = form.isAllDay, + locale = locale, + onDateClick = { onPick(PickerTarget.EndDate) }, + onTimeClick = { onPick(PickerTarget.EndTime) }, + isError = EventFormProblem.EndBeforeStart in state.problems, + enabled = !locked, + ) + if (EventFormProblem.EndBeforeStart in state.problems) { + Spacer(Modifier.height(2.dp)) + Text( + text = stringResource(R.string.event_edit_error_end_before_start), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + // The rows above edit a pinned event in *its own* zone, which is the + // time it was set at — but that says nothing about when it lands for + // whoever is reading it. Spell the local equivalent out rather than + // leaving the user to do the offset arithmetic. Absent unless the + // event is pinned somewhere other than here. + // Keyed rather than recomputed: the form recomposes on every title + // keystroke, and this would otherwise re-parse the zone each time. + val pinnedAway = remember(form.timezone, form.isAllDay, deviceZone) { + form.isPinnedAwayFrom(deviceZone) + } + if (pinnedAway) { + Spacer(Modifier.height(2.dp)) + Text( + text = stringResource( + R.string.event_edit_timezone_local_time, + formatTimeRange(startAt, endAt, deviceZone, locale), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun CalendarCard(calendar: CalendarSource?, accent: Color, onClick: (() -> Unit)?) { + IconInfoCard( + icon = Icons.Default.CalendarMonth, + iconContentDescription = stringResource(R.string.event_detail_calendar), + iconTint = accent, + onClick = onClick, + ) { + Text( + text = calendar?.displayName + ?.orUnnamed() + ?: stringResource(R.string.event_edit_error_no_calendar), + style = MaterialTheme.typography.titleMedium, + color = if (calendar == null) { + MaterialTheme.colorScheme.error + } else { + Color.Unspecified + }, + ) + calendar?.accountName?.takeIf { it.isNotBlank() }?.let { account -> + Text( + text = account, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun TimezoneCard(form: EventForm, locale: Locale, onClick: () -> Unit) { + IconInfoCard( + icon = Icons.Default.Public, + iconContentDescription = null, + onClick = onClick, + ) { + // Resolved at the event's own start, not at "now": the + // abbreviation and offset shown ("CEST · GMT+02:00") must be + // the ones that apply when the event actually happens. + val pinned = remember(form.timezone, form.start, locale) { + form.timezone + ?.let { id -> runCatching { id to TimeZone.of(id) }.getOrNull() } + ?.let { (id, zone) -> + timeZoneOptionOf( + id, + locale, + at = form.startInstant(zone).toJavaInstant(), + regionOf = ::icuTimeZoneRegion, + ) + } + } + PickerCardBody( + title = pinned?.label ?: stringResource(R.string.event_edit_timezone_device), + subtitle = pinned?.let { zoneDescriptor(it) } + ?: stringResource(R.string.event_edit_timezone_device_summary), + ) + } +} + +@Composable +private fun LocationCard( + location: String, + onLocationChange: (String) -> Unit, + onPickContact: () -> Unit, +) { + IconInfoCard( + icon = Icons.Default.Place, + iconContentDescription = stringResource(R.string.event_detail_location), + iconAlignment = Alignment.Top, + iconTopPadding = ((firstLineHeight() - 24.dp) / 2).coerceAtLeast(0.dp), + ) { + Row(verticalAlignment = Alignment.Top) { + Column(modifier = Modifier.weight(1f)) { + InlineTextField( + value = location, + onValueChange = onLocationChange, + placeholder = stringResource(R.string.event_detail_location), + // A location is as often a meeting URL as an address, + // and "Zoom.us/j/123" reads wrong (#146). + capitalization = KeyboardCapitalization.None, + // Multi-line so a long address or meeting URL wraps and + // the card grows instead of scrolling off one line + // (#273). The field holds no newlines of its own, so + // the IME's action key closes the keyboard rather than + // inserting a break setLocation would only fold away. + singleLine = false, + imeAction = ImeAction.Done, + modifier = Modifier + .fillMaxWidth() + .animateContentSizeMotion() + .padding(vertical = 4.dp), + ) + FieldLengthCounter( + length = location.length, + max = MAX_SINGLE_LINE_FIELD, + ) + } + // The box is exactly one text line tall, so the button + // centres on the first line without making a single-line + // card taller than its text; requiredSize keeps the + // button's own 40dp ripple. + Box( + modifier = Modifier.height(firstLineHeight()), + contentAlignment = Alignment.Center, + ) { + IconButton( + onClick = onPickContact, + modifier = Modifier.requiredSize(40.dp), + ) { + Icon( + imageVector = Icons.Default.Contacts, + contentDescription = stringResource( + R.string.event_edit_location_from_contacts, + ), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + } + } + } + } +} + +/** + * A grouped list like guests: each reminder is its own tonal card with + * connected corners, the last row being "Add reminder". + */ +@Composable +private fun RemindersSection(reminders: List, onRemove: (Int) -> Unit, onAdd: () -> Unit) { + val rows = reminders.size + 1 + reminders.forEachIndexed { index, minutes -> + GroupedItemCard( + position = positionOf(index, rows), + icon = Icons.Default.Notifications, + title = reminderLeadTimeLabel(minutes), + removeContentDescription = stringResource(R.string.event_edit_remove_reminder), + onRemove = { onRemove(minutes) }, + ) + } + AddActionCard( + label = stringResource(R.string.event_edit_add_reminder), + position = positionOf(reminders.size, rows), + onClick = onAdd, + ) +} + +@Composable +private fun RecurrenceCard(state: EventEditUiState, locale: Locale, onClick: (() -> Unit)?) { + IconInfoCard( + icon = Icons.Default.Repeat, + iconContentDescription = null, + onClick = onClick, + ) { + PickerCardBody( + title = state.form.rrule?.let { recurrenceText(it, locale) } + ?: AnnotatedString(stringResource(R.string.event_edit_recurrence_none)), + subtitle = stringResource(R.string.event_detail_recurrence), + ) + if (EventFormProblem.RecurrenceEndsBeforeStart in state.problems) { + Spacer(Modifier.height(2.dp)) + Text( + text = stringResource(R.string.event_edit_error_recurrence_ends_before_start), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AvailabilityCard(availability: Availability, onSelect: (Availability) -> Unit) { + IconInfoCard( + icon = Icons.Default.EventAvailable, + iconContentDescription = null, + header = { + Text( + text = stringResource(R.string.event_edit_availability), + style = MaterialTheme.typography.titleMedium, + ) + }, + ) { + Spacer(Modifier.height(8.dp)) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + SegmentedButton( + selected = availability == Availability.Busy, + onClick = { onSelect(Availability.Busy) }, + shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), + ) { Text(stringResource(R.string.event_availability_busy)) } + SegmentedButton( + selected = availability == Availability.Free, + onClick = { onSelect(Availability.Free) }, + shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), + ) { Text(stringResource(R.string.event_availability_free)) } + } + } +} + +/** + * The swatch the event will paint with: its own [color], else the calendar's. + * The Palette icon takes that colour as a preview. + */ +@Composable +private fun ColorCard( + color: Int?, + calendarColor: Int?, + supported: Boolean, + syncRisk: Boolean, + onClick: (() -> Unit)?, +) { + val swatch = color ?: calendarColor + IconInfoCard( + icon = Icons.Default.Palette, + iconContentDescription = stringResource(R.string.event_edit_color), + iconTint = if (supported && swatch != null) { + eventFill(swatch, LocalFloretDarkTheme.current, LocalSoftenColors.current) + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + onClick = onClick, + ) { + PickerCardBody( + title = stringResource( + when { + !supported -> R.string.event_edit_color_unsupported + color != null -> R.string.event_edit_color_custom + else -> R.string.event_edit_color_default + }, + ), + subtitle = stringResource( + if (supported) R.string.event_edit_color else R.string.event_edit_color_unsupported_hint, + ), + showArrow = supported, + ) { + if (syncRisk) { + Spacer(Modifier.height(2.dp)) + Text( + text = stringResource(R.string.event_edit_color_sync_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +/** + * Guests are an inline grouped list: each is a tonal card with a role toggle + + * remove, and the trailing card is an inline email field — type an address, + * press Done, it commits. Below sits an honest note — Calendula never sends + * invitations, so whether a guest is notified is the calendar backend's call. + */ +@Composable +private fun GuestsSection( + guests: List, + isLocalCalendar: Boolean, + viewModel: EventEditViewModel, + onPickContact: () -> Unit, +) { + val rows = guests.size + 1 + guests.forEachIndexed { index, attendee -> + GuestCard( + attendee = attendee, + position = positionOf(index, rows), + onToggleOptional = { viewModel.setAttendeeOptional(attendee, it) }, + onRemove = { viewModel.removeAttendee(attendee) }, + ) + } + AddGuestInlineCard( + position = positionOf(guests.size, rows), + onAdd = { viewModel.addAttendee(it) }, + onPickContact = onPickContact, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource( + if (isLocalCalendar) { + R.string.event_edit_attendees_note_local + } else { + R.string.event_edit_attendees_note_synced + }, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp), + ) +} + +/** The open [sheet]; each one closes itself through [onClose] once it has applied its pick. */ +@Composable +private fun EditSheetHost( + sheet: EditSheet, + state: EventEditUiState, + viewModel: EventEditViewModel, + firstDayOfWeek: DayOfWeek, + colorSyncRisk: Boolean, + onManageCalendars: (() -> Unit)?, + onClose: () -> Unit, +) { + val form = state.form + // Read in the form's own window, not the picker's: the focused field lives + // there, so this is the controller that can put its keyboard away. + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + when (sheet) { + EditSheet.Calendar -> { + // A full-screen picker is a change of place, so the keyboard shouldn't + // follow it. The field keeps its text; only focus and the IME go. + LaunchedEffect(Unit) { + focusManager.clearFocus(force = true) + keyboardController?.hide() + } + CalendarPicker( + calendars = state.calendars, + selectedId = form.calendarId, + onSelect = { + viewModel.setCalendar(it) + onClose() + }, + // Close the picker first: it is a Compose Dialog in its own window, + // always above the activity's content, so the manager would + // otherwise open behind it and the tap would look dead. + onManageCalendars = onManageCalendars?.let { openManager -> + { + onClose() + openManager() + } + }, + onDismiss = onClose, + ) + } + EditSheet.Reminder -> ReminderPickerDialog( alreadyChosen = form.reminders, onSelect = { minutes -> viewModel.addReminder(minutes) - showReminderPicker = false + onClose() }, - onDismiss = { showReminderPicker = false }, + onDismiss = onClose, ) - } - - - if (showRecurrencePicker) { - RecurrencePickerDialog( + EditSheet.Recurrence -> RecurrencePickerDialog( current = form.rrule, startDate = form.start.date, firstDayOfWeek = firstDayOfWeek, onSelect = { rrule -> viewModel.setRecurrence(rrule) - showRecurrencePicker = false + onClose() }, - onDismiss = { showRecurrencePicker = false }, + onDismiss = onClose, ) - } - - if (showVisibilityPicker) { - VisibilityPickerDialog( + EditSheet.Visibility -> VisibilityPickerDialog( selected = form.accessLevel, onSelect = { level -> viewModel.setAccessLevel(level) - showVisibilityPicker = false + onClose() }, - onDismiss = { showVisibilityPicker = false }, + onDismiss = onClose, ) - } - - if (showTimezonePicker) { - TimeZonePickerDialog( + EditSheet.Timezone -> TimeZonePickerDialog( selected = form.timezone, deviceZoneId = ZoneId.systemDefault().id, recents = state.recentTimeZones, onSelect = viewModel::setTimezone, - onDismiss = { showTimezonePicker = false }, + onDismiss = onClose, ) - } - - if (showColorPicker) { - ColorPickerDialog( + EditSheet.Color -> ColorPickerDialog( palette = state.colorPalette, selected = form.color, hasExplicitColor = form.color != null, syncWarning = colorSyncRisk, onPickKey = { key, argb -> viewModel.setColorKey(key, argb) - showColorPicker = false + onClose() }, onPickRaw = { argb -> viewModel.setColorRaw(argb) - showColorPicker = false + onClose() }, onClear = { viewModel.clearColor() - showColorPicker = false + onClose() }, - onDismiss = { showColorPicker = false }, + onDismiss = onClose, ) - } - - if (showFieldPicker) { - FieldPickerDialog( + EditSheet.Field -> FieldPickerDialog( hiddenFields = state.hiddenFields, onSelect = { field -> viewModel.revealField(field) - showFieldPicker = false + onClose() }, - onDismiss = { showFieldPicker = false }, + onDismiss = onClose, ) } } @@ -1497,9 +1568,6 @@ private fun FieldPickerDialog( } } -/** Quick-pick lead times offered as chips in the reminder dialog. */ -private val REMINDER_QUICK_PICKS = REMINDER_PRESETS - /** * Reminder picker, two steps: the common lead times as a tappable list * (one tap adds and closes), with "Custom" at the bottom switching to an @@ -1533,11 +1601,11 @@ private fun ReminderPickerDialog( }, ) { if (!customMode) { - val presets = REMINDER_QUICK_PICKS.filterNot { it in alreadyChosen } + val presets = REMINDER_PRESETS.filterNot { it in alreadyChosen } val rowCount = presets.size + 1 presets.forEachIndexed { index, minutes -> GroupedRow( - title = reminderLabel(minutes), + title = reminderLeadTimeLabel(minutes), position = positionOf(index, rowCount), onClick = { onSelect(minutes) }, ) @@ -2344,10 +2412,6 @@ private fun accessLevelSummary(level: AccessLevel): Int = when (level) { AccessLevel.Confidential -> R.string.event_access_confidential_summary } -/** Humanise a reminder lead time, mirroring the detail screen's rendering. */ -@Composable -private fun reminderLabel(minutes: Int): String = reminderLeadTimeLabel(minutes) - /** * "2:00 PM – 3:00 PM" in [zone], honouring the user's 12/24-hour setting. Collapses * to one time for an instant event, which shouldn't read as a range against @@ -2369,45 +2433,7 @@ private fun formatTimeRange(start: Instant, end: Instant, zone: TimeZone, locale } /** - * Borderless text input used inside the cards (and as the headline title). - * Thin wrapper over the shared [InlineTextField] so the form and the rest of - * the app share one input style. Sentence-case by default, as floret-kit is; - * a field holding an identifier rather than prose passes - * [KeyboardCapitalization.None]. - */ -@Composable -private fun InlineField( - value: String, - onValueChange: (String) -> Unit, - placeholder: String, - textStyle: TextStyle = MaterialTheme.typography.titleMedium, - singleLine: Boolean = true, - minLines: Int = 1, - enabled: Boolean = true, - keyboardType: KeyboardType = KeyboardType.Text, - capitalization: KeyboardCapitalization = KeyboardCapitalization.Sentences, - imeAction: ImeAction = ImeAction.Default, - modifier: Modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), -) { - InlineTextField( - value = value, - onValueChange = onValueChange, - placeholder = placeholder, - modifier = modifier, - textStyle = textStyle, - singleLine = singleLine, - minLines = minLines, - enabled = enabled, - keyboardType = keyboardType, - capitalization = capitalization, - imeAction = imeAction, - ) -} - -/** - * A right-aligned "n/max" counter for a capped [InlineField] (#331), hidden + * A right-aligned "n/max" counter for a capped [InlineTextField] (#331), hidden * until [length] gets close enough to [max] to matter — nobody needs to see * "12/255" while typing a short title. */ @@ -2429,7 +2455,7 @@ private fun FieldLengthCounter(length: Int, max: Int, modifier: Modifier = Modif } /** - * Height of one [InlineField] line: the resolved [TextStyle.lineHeight] plus the + * Height of one [InlineTextField] line: the resolved [TextStyle.lineHeight] plus the * field's 4dp vertical padding. In dp, so it tracks the user's font scale * instead of assuming the 24sp line of a 1.0 scale (#165). */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt index 4ea3034..e84d382 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt @@ -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 = todaySource.today - .onEach { date -> - val old = lastToday - lastToday = date - if (old != date) followToday(old, date) - } - .stateWhileSubscribed(viewModelScope, todayDate) + val today: StateFlow = + todaySource.today.stateFollowingDays(viewModelScope, todayDate, ::followToday) private val _month = MutableStateFlow(todayDate.yearMonth) val month: StateFlow = _month diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/TimelineActions.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/TimelineActions.kt new file mode 100644 index 0000000..2a0e69c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/TimelineActions.kt @@ -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) }, + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 2097fbd..6eaa28e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -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, 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() } 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, 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) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt index 3655532..4128ef7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt @@ -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 = todaySource.today - .onEach { date -> - val old = lastToday - lastToday = date - if (old != date) followToday(old, date) - } - .stateWhileSubscribed(viewModelScope, todayDate) + private val today: StateFlow = + todaySource.today.stateFollowingDays(viewModelScope, todayDate, ::followToday) private val position: StateFlow = combine(_anchor, rule) { anchor, rule -> positionOf(anchor, rule) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt index d9790ce..e7d87a0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewPreview.kt @@ -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 = {}, + ), ) } }