Register an intent-filter for ACTION_INSERT on the events dir mime type (vnd.android.cursor.dir/event), the way the AOSP calendar accepts one, so other apps and widgets (e.g. the Todo Agenda widget) can launch Calendula to create a new event. MainActivity.insertFormOrNull parses the standard CalendarContract extras (EXTRA_EVENT_BEGIN_TIME/END_TIME/ALL_DAY, Events.TITLE/DESCRIPTION/ EVENT_LOCATION/RRULE) into a prefilled EventForm via the pure, unit-tested buildInsertEventForm — omitted fields fall back to the same defaults the in-app "new event" uses (next full hour, +1h). The form is routed through the existing single-event prefill channel (RootScreen → CalendarHost → the create form for review), with calendarId left null so it resolves to the last-used / first-writable calendar. No new permission is needed (WRITE_CALENDAR is already held), and the user still explicitly saves. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
107 lines
4.8 KiB
Kotlin
107 lines
4.8 KiB
Kotlin
package de.jeanlucmakiola.calendula.ui
|
|
|
|
import android.Manifest
|
|
import android.content.pm.PackageManager
|
|
import androidx.compose.animation.Crossfade
|
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.DisposableEffect
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.platform.LocalContext
|
|
import androidx.hilt.navigation.compose.hiltViewModel
|
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
import androidx.core.content.ContextCompat
|
|
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.permission.PermissionScreen
|
|
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
|
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
|
|
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
@Composable
|
|
fun RootScreen(
|
|
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,
|
|
onInsertConsumed: () -> Unit = {},
|
|
) {
|
|
val context = LocalContext.current
|
|
var hasPermission by remember {
|
|
mutableStateOf(
|
|
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR)
|
|
== PackageManager.PERMISSION_GRANTED
|
|
)
|
|
}
|
|
|
|
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
|
DisposableEffect(lifecycle) {
|
|
val obs = LifecycleEventObserver { _, event ->
|
|
if (event == Lifecycle.Event.ON_RESUME) {
|
|
hasPermission = ContextCompat.checkSelfPermission(
|
|
context, Manifest.permission.READ_CALENDAR
|
|
) == PackageManager.PERMISSION_GRANTED
|
|
// 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.
|
|
if (context.hasContactsPermission()) {
|
|
SpecialDatesScheduler.runNow(context, foreground = true)
|
|
}
|
|
}
|
|
}
|
|
lifecycle.addObserver(obs)
|
|
onDispose { lifecycle.removeObserver(obs) }
|
|
}
|
|
|
|
// Cross-fade the one-time onboarding gates so granting permission / finishing
|
|
// onboarding eases into the next screen instead of snapping. A fade carries no
|
|
// spatial motion, so it stays appropriate under "remove animations" too.
|
|
val gateSpec = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
|
Crossfade(targetState = hasPermission, animationSpec = gateSpec, label = "permissionGate") { granted ->
|
|
if (granted) {
|
|
// Second onboarding gate (v1.4, one-time): reminder notifications.
|
|
// Null until DataStore's first emission — render nothing for that
|
|
// frame instead of flashing the wrong screen.
|
|
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
|
|
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
|
|
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
|
|
when (done) {
|
|
true -> CalendarHost(
|
|
modifier = modifier,
|
|
requestedDetailKey = requestedDetailKey,
|
|
onDetailKeyConsumed = onDetailKeyConsumed,
|
|
widgetNavRequest = widgetNavRequest,
|
|
onWidgetNavConsumed = onWidgetNavConsumed,
|
|
requestedImportUri = requestedImportUri,
|
|
onImportConsumed = onImportConsumed,
|
|
requestedInsertForm = requestedInsertForm,
|
|
onInsertConsumed = onInsertConsumed,
|
|
)
|
|
false -> ReminderOnboardingScreen(
|
|
onFinished = reminderOnboarding::finish,
|
|
modifier = modifier,
|
|
)
|
|
null -> {}
|
|
}
|
|
}
|
|
} else {
|
|
PermissionScreen(
|
|
onGranted = { hasPermission = true },
|
|
modifier = modifier,
|
|
)
|
|
}
|
|
}
|
|
}
|