All checks were successful
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/172
175 lines
7.4 KiB
Kotlin
175 lines
7.4 KiB
Kotlin
package de.jeanlucmakiola.calendula.ui
|
|
|
|
import android.Manifest
|
|
import android.content.pm.PackageManager
|
|
import androidx.compose.animation.AnimatedContent
|
|
import androidx.compose.animation.fadeIn
|
|
import androidx.compose.animation.fadeOut
|
|
import androidx.compose.animation.togetherWith
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.DisposableEffect
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.SideEffect
|
|
import androidx.compose.runtime.mutableIntStateOf
|
|
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.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.identity.rememberReduceMotion
|
|
|
|
/** What the root is showing: a wizard step, the app, or neither yet. */
|
|
private sealed interface RootTarget {
|
|
/** DataStore has not emitted yet — render nothing rather than the wrong screen. */
|
|
data object Loading : RootTarget
|
|
data object App : RootTarget
|
|
data class Step(val step: OnboardingStep) : RootTarget
|
|
}
|
|
|
|
@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,
|
|
requestedInsertSource: de.jeanlucmakiola.calendula.ui.edit.ImportSource =
|
|
de.jeanlucmakiola.calendula.ui.edit.ImportSource.Insert,
|
|
onInsertConsumed: () -> Unit = {},
|
|
requestedEditKey: LongArray? = null,
|
|
onEditKeyConsumed: () -> Unit = {},
|
|
) {
|
|
val context = LocalContext.current
|
|
var hasPermission by remember {
|
|
mutableStateOf(
|
|
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR)
|
|
== PackageManager.PERMISSION_GRANTED
|
|
)
|
|
}
|
|
|
|
// A launch scan already covers the app coming up with the permission, so
|
|
// only a grant made during this session owes a re-scan.
|
|
val grantedAtLaunch = remember { hasPermission }
|
|
|
|
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) }
|
|
}
|
|
|
|
val onboarding: OnboardingViewModel = hiltViewModel()
|
|
val plan by onboarding.plan.collectAsStateWithLifecycle()
|
|
// The grant is reported however it was taken, including via Android's
|
|
// app-settings screen (caught by the ON_RESUME above); arming the wizard
|
|
// hangs off that, not off the in-app callback.
|
|
LaunchedEffect(hasPermission) {
|
|
onboarding.setHasPermission(hasPermission)
|
|
if (hasPermission && !grantedAtLaunch) onboarding.onPermissionGranted()
|
|
}
|
|
|
|
// Arms the one-time explainer for the switch to the device's own calendar
|
|
// visibility (#75), which the wizard then carries as a step of its own.
|
|
LaunchedEffect(hasPermission) {
|
|
if (hasPermission) onboarding.reconcileCalendarVisibility()
|
|
}
|
|
|
|
val current = plan
|
|
val target: RootTarget = when {
|
|
current == null -> RootTarget.Loading
|
|
current.current == null -> RootTarget.App
|
|
else -> RootTarget.Step(current.current)
|
|
}
|
|
|
|
// Which way the flow is moving, so a step-back slides back. The app sorts
|
|
// after every step, so finishing the wizard reads as one more move forward.
|
|
val ordinal = when (target) {
|
|
RootTarget.Loading -> Int.MIN_VALUE
|
|
RootTarget.App -> Int.MAX_VALUE
|
|
is RootTarget.Step -> current?.steps?.indexOf(target.step) ?: 0
|
|
}
|
|
var lastOrdinal by remember { mutableIntStateOf(ordinal) }
|
|
val slideDir = if (ordinal < lastOrdinal) -1 else 1
|
|
SideEffect { lastOrdinal = ordinal }
|
|
|
|
val slideSpec = rememberOnboardingSlideSpec()
|
|
val fadeSpec = rememberCalendarFadeSpec()
|
|
val reduceMotion = rememberReduceMotion()
|
|
AnimatedContent(
|
|
targetState = target,
|
|
transitionSpec = {
|
|
// Coming off the blank first frame is not a step change.
|
|
if (initialState == RootTarget.Loading) {
|
|
fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
|
} else {
|
|
onboardingSlideTransition(
|
|
slideDir = slideDir,
|
|
spec = slideSpec,
|
|
fadeSpec = fadeSpec,
|
|
reduceMotion = reduceMotion,
|
|
)
|
|
}
|
|
},
|
|
label = "onboardingGate",
|
|
) { shown ->
|
|
when (shown) {
|
|
RootTarget.Loading -> Unit
|
|
RootTarget.App -> CalendarHost(
|
|
modifier = modifier,
|
|
requestedDetailKey = requestedDetailKey,
|
|
onDetailKeyConsumed = onDetailKeyConsumed,
|
|
widgetNavRequest = widgetNavRequest,
|
|
onWidgetNavConsumed = onWidgetNavConsumed,
|
|
requestedImportUri = requestedImportUri,
|
|
onImportConsumed = onImportConsumed,
|
|
requestedInsertForm = requestedInsertForm,
|
|
requestedInsertSource = requestedInsertSource,
|
|
onInsertConsumed = onInsertConsumed,
|
|
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.
|
|
is RootTarget.Step -> current?.let { plan ->
|
|
OnboardingSteps(
|
|
step = shown.step,
|
|
plan = plan,
|
|
viewModel = onboarding,
|
|
onPermissionGranted = { hasPermission = true },
|
|
modifier = modifier,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|