Hand external requests to the host as one holder

This commit is contained in:
2026-10-16 14:07:39 +02:00
parent 28c4e68de4
commit 11c1b71c85
5 changed files with 98 additions and 115 deletions
@@ -36,6 +36,8 @@ import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
import de.jeanlucmakiola.calendula.domain.sharedTextOrNull
import de.jeanlucmakiola.calendula.ui.ExternalRequests
import de.jeanlucmakiola.calendula.ui.InsertRequest
import de.jeanlucmakiola.calendula.ui.RootScreen
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid
import de.jeanlucmakiola.calendula.ui.common.LocalMultiDayCount
@@ -67,9 +69,6 @@ import kotlin.time.Clock
import kotlin.time.Instant
import javax.inject.Inject
/** A prefilled create form from an external launch, with the source it came from. */
private data class InsertRequest(val form: EventForm, val source: ImportSource)
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@@ -81,23 +80,12 @@ class MainActivity : AppCompatActivity() {
// picks up the in-app override instead of the night resource qualifier.
private var systemBarsDark = false
// The occurrence a reminder notification was tapped for (eventId, begin,
// end — the detail screen's key shape). singleTop + onNewIntent route a
// tap into the running activity; CalendarHost consumes and clears it.
private var requestedDetailKey by mutableStateOf<LongArray?>(null)
// A navigation a home-screen widget asked for (open a date / start a
// create). Consumed once by CalendarHost, same pattern as the detail key.
private var requestedNav by mutableStateOf<WidgetNavRequest?>(null)
// An .ics file opened/shared into the app (ACTION_VIEW/SEND). Consumed once
// by CalendarHost's import flow.
private var requestedImportUri by mutableStateOf<Uri?>(null)
// A prefilled new-event form from an external ACTION_INSERT launch (another
// app/widget asking us to create an event, issue #30) or shared text (#306).
// Consumed once by CalendarHost, which opens it in the create form for review.
private var requestedInsert by mutableStateOf<InsertRequest?>(null)
private val requests = ExternalRequests(
onExternalFormClosed = {
insertFormOpen = false
if (launchedForInsert) finish()
},
)
// Whether such a form is still open. Only then does a recreation re-read the
// intent to bring it back; a saved or dismissed one stays closed.
@@ -107,11 +95,6 @@ class MainActivity : AppCompatActivity() {
// app that asked instead of leaving the calendar on top of it.
private var launchedForInsert = false
// An external "edit this event" (ACTION_EDIT on content://.../events/<id>):
// opens the occurrence in the edit form. Same occurrence-key shape as the
// detail channel; consumed once by CalendarHost.
private var requestedEditKey by mutableStateOf<LongArray?>(null)
// A captured crash report awaiting the user's decision, surfaced as a dialog
// over the calendar on the next launch (the single-crash path). A startup
// crash-loop is handled out of band, before setContent — see below.
@@ -161,17 +144,17 @@ class MainActivity : AppCompatActivity() {
// it's still open.
val replaying = savedInstanceState != null || intent.isRelaunch()
if (!replaying) {
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
requestedEditKey = intent.editEventKeyOrNull()
requests.detailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
requests.editKey = intent.editEventKeyOrNull()
}
requestedNav = intent.navRequestOrNull()
requests.nav = intent.navRequestOrNull()
?.takeUnless { replaying && it is WidgetNavRequest.OpenEvent }
requestedImportUri = intent.importUriOrNull()
requests.importUri = intent.importUriOrNull()
val insertOpenBefore = savedInstanceState?.getBoolean(KEY_INSERT_FORM_OPEN) == true
requestedInsert = intent.createRequestOrNull()?.takeIf { !replaying || insertOpenBefore }
insertFormOpen = requestedInsert != null
requests.insert = intent.createRequestOrNull()?.takeIf { !replaying || insertOpenBefore }
insertFormOpen = requests.insert != null
launchedForInsert = savedInstanceState?.getBoolean(KEY_LAUNCHED_FOR_INSERT)
?: (requestedInsert != null)
?: (requests.insert != null)
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
setContent {
// One activity-scoped SettingsViewModel drives both the theme here
@@ -230,21 +213,7 @@ class MainActivity : AppCompatActivity() {
) {
RootScreen(
modifier = Modifier.fillMaxSize(),
requestedDetailKey = requestedDetailKey,
onDetailKeyConsumed = { requestedDetailKey = null },
widgetNavRequest = requestedNav,
onWidgetNavConsumed = { requestedNav = null },
requestedImportUri = requestedImportUri,
onImportConsumed = { requestedImportUri = null },
requestedInsertForm = requestedInsert?.form,
requestedInsertSource = requestedInsert?.source ?: ImportSource.Insert,
onInsertConsumed = { requestedInsert = null },
onExternalFormClosed = {
insertFormOpen = false
if (launchedForInsert) finish()
},
requestedEditKey = requestedEditKey,
onEditKeyConsumed = { requestedEditKey = null },
requests = requests,
)
}
// A persistent corner marker so a debug build is never
@@ -304,14 +273,14 @@ class MainActivity : AppCompatActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it }
intent.navRequestOrNull()?.let { requestedNav = it }
intent.importUriOrNull()?.let { requestedImportUri = it }
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requests.detailKey = it }
intent.navRequestOrNull()?.let { requests.nav = it }
intent.importUriOrNull()?.let { requests.importUri = it }
intent.createRequestOrNull()?.let {
requestedInsert = it
requests.insert = it
insertFormOpen = true
}
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
intent.editEventKeyOrNull()?.let { requests.editKey = it }
}
/**
@@ -116,29 +116,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
@@ -210,11 +199,12 @@ fun CalendarHost(
}
// 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
}
}
@@ -306,7 +296,7 @@ fun CalendarHost(
importForm = null
if (importFromRequest) {
importFromRequest = false
onExternalFormClosed()
requests.onExternalFormClosed()
}
}
}
@@ -325,26 +315,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
}
}
@@ -369,19 +361,21 @@ fun CalendarHost(
// 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.
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++
@@ -397,7 +391,7 @@ fun CalendarHost(
// 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
@@ -408,7 +402,7 @@ fun CalendarHost(
val key = longArrayOf(req.eventId, req.beginMillis, req.endMillis)
heldKey = key
detailKey = key
onWidgetNavConsumed()
requests.nav = null
}
is WidgetNavRequest.OpenView -> {
// A widget header tap: land on a top-level view with no date
@@ -419,7 +413,7 @@ fun CalendarHost(
createDateIso = null
focus.navigateTo(localToday())
viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
onWidgetNavConsumed()
requests.nav = null
}
is WidgetNavRequest.Create -> {
// External "new event" entries (QS tile / launcher shortcut /
@@ -435,7 +429,7 @@ fun CalendarHost(
createStartMinutes = null
heldCreateDraftMillis = null
createDraftMillis = null
onWidgetNavConsumed()
requests.nav = null
}
null -> {}
}
@@ -0,0 +1,42 @@
package de.jeanlucmakiola.calendula.ui
import android.net.Uri
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
/**
* What a notification, widget, shortcut or another app asked the running app to
* open. MainActivity fills it from each intent (singleTop + onNewIntent route a
* new one into the running activity); CalendarHost acts on a request and clears
* it, so a later recomposition can't act on it twice.
*
* [onExternalFormClosed] runs when a form opened for [insert] closes, so the
* activity can hand control back to the app that asked.
*/
@Stable
class ExternalRequests(val onExternalFormClosed: () -> Unit = {}) {
/** A tapped reminder notification's occurrence (eventId, begin, end), for the detail screen. */
var detailKey by mutableStateOf<LongArray?>(null)
/** A home-screen widget, shortcut or tile navigation (open a date, an event, a view, or create). */
var nav by mutableStateOf<WidgetNavRequest?>(null)
/** An .ics file opened or shared into the app (ACTION_VIEW/SEND). */
var importUri by mutableStateOf<Uri?>(null)
/**
* A prefilled new-event form from an external ACTION_INSERT launch (#30) or
* shared text (#306), opened in the create form for review.
*/
var insert by mutableStateOf<InsertRequest?>(null)
/** An external "edit this event" (ACTION_EDIT), in the detail key's shape. */
var editKey by mutableStateOf<LongArray?>(null)
}
/** A prefilled create form from an external launch, with the source it came from. */
data class InsertRequest(val form: EventForm, val source: ImportSource)
@@ -1,6 +1,5 @@
package de.jeanlucmakiola.calendula.ui
import android.Manifest
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -23,13 +22,13 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
import de.jeanlucmakiola.calendula.ui.common.canReadCalendar
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingStep
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingSteps
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingViewModel
import de.jeanlucmakiola.calendula.ui.onboarding.onboardingSlideTransition
import de.jeanlucmakiola.calendula.ui.onboarding.rememberOnboardingSlideSpec
import de.jeanlucmakiola.floret.android.hasPermission
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
/** What the root is showing: a wizard step, the app, or neither yet. */
@@ -42,26 +41,12 @@ private sealed interface RootTarget {
@Composable
fun RootScreen(
requests: ExternalRequests,
modifier: Modifier = Modifier,
requestedDetailKey: LongArray? = null,
onDetailKeyConsumed: () -> Unit = {},
widgetNavRequest: WidgetNavRequest? = null,
onWidgetNavConsumed: () -> Unit = {},
requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {},
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
requestedInsertSource: de.jeanlucmakiola.calendula.ui.edit.ImportSource =
de.jeanlucmakiola.calendula.ui.edit.ImportSource.Insert,
onInsertConsumed: () -> Unit = {},
onExternalFormClosed: () -> Unit = {},
requestedEditKey: LongArray? = null,
onEditKeyConsumed: () -> Unit = {},
) {
val context = LocalContext.current
var hasPermission by remember {
mutableStateOf(
context.hasPermission(Manifest.permission.READ_CALENDAR)
)
mutableStateOf(context.canReadCalendar())
}
// A launch scan already covers the app coming up with the permission, so
@@ -72,7 +57,7 @@ fun RootScreen(
DisposableEffect(lifecycle) {
val obs = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
hasPermission = context.hasPermission(Manifest.permission.READ_CALENDAR)
hasPermission = context.canReadCalendar()
// Refresh the contact special-dates mirror on foreground (the
// worker is debounced and no-ops when the feature is off). Gated
// on the permission so users without the opt-in never enqueue it.
@@ -142,19 +127,8 @@ fun RootScreen(
when (shown) {
RootTarget.Loading -> Unit
RootTarget.App -> CalendarHost(
requests = requests,
modifier = modifier,
requestedDetailKey = requestedDetailKey,
onDetailKeyConsumed = onDetailKeyConsumed,
widgetNavRequest = widgetNavRequest,
onWidgetNavConsumed = onWidgetNavConsumed,
requestedImportUri = requestedImportUri,
onImportConsumed = onImportConsumed,
requestedInsertForm = requestedInsertForm,
requestedInsertSource = requestedInsertSource,
onInsertConsumed = onInsertConsumed,
onExternalFormClosed = onExternalFormClosed,
requestedEditKey = requestedEditKey,
onEditKeyConsumed = onEditKeyConsumed,
)
// Both halves of a transition read their position out of the live
// plan; the one the outgoing step was drawn with is already gone.
@@ -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)