Hand external requests to the host as one holder
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user