diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt index 3d0e3994..d0d4fe58 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/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index b16611fa..01234ce7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -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 -> {} } 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 00000000..6402696f --- /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 c29f0ab6..033377c2 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/common/EventActions.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventActions.kt index b9bef700..d4d461b3 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)