Fix the import flow's exit and a repeated import (#304)

Two things found reviewing the restore row with a folder of .ics files:

Starting a restore closes Backup & restore and the manager, since the import
overlays are declared under them, and nothing put them back — so finishing an
import dropped you on the calendar and the next file meant walking in through
Settings again. The host now restores whichever surface the restore came from.

Importing the same file twice showed the first run's summary and imported
nothing: the screen keyed its VM by the file uri, the VM lives in the
Activity's store, and its one-shot load guard was already tripped. The reload
now follows an import session number, which a rotation keeps so a finished
result still survives recreation.
This commit is contained in:
2026-09-22 10:03:15 +02:00
parent 0d04268c1b
commit a654182255
3 changed files with 56 additions and 16 deletions
@@ -197,10 +197,21 @@ fun CalendarHost(
// is "restore a backup", not "add this one event". An externally opened .ics
// keeps routing a single event straight into the prefilled create form.
var importForceMany by remember { mutableStateOf(false) }
// Where a restore came from, so closing the import puts it back: the import
// overlays are declared under Backup & restore and the manager, so starting
// one has to close them — without this, finishing a restore drops you on the
// calendar and the next file means walking in through Settings again.
var backupAfterImport by rememberSaveable { mutableStateOf(false) }
var calendarsAfterImport by rememberSaveable { mutableStateOf(false) }
// One import run. The import VM lives in the Activity's store, so this is
// 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) }
LaunchedEffect(requestedImportUri) {
if (requestedImportUri != null) {
importUri = requestedImportUri
importForceMany = false
importSession++
onImportConsumed()
}
}
@@ -510,11 +521,28 @@ fun CalendarHost(
importUri?.let { uri ->
ImportScreen(
uri = uri,
session = importSession,
forceMany = importForceMany,
onClose = { importUri = null },
onManageCalendars = { showCalendars = true },
onClose = {
importUri = null
// Back to the surface the restore started from, ready for
// the next file.
showCalendars = calendarsAfterImport
showBackup = backupAfterImport
calendarsAfterImport = false
backupAfterImport = false
},
onManageCalendars = {
// The manager is where this leads, so it must not be
// reopened underneath on close.
calendarsAfterImport = false
backupAfterImport = false
showCalendars = true
},
onOpenSingle = { form ->
importUri = null
calendarsAfterImport = false
backupAfterImport = false
importFormSource = ImportSource.File
importForm = form
},
@@ -569,6 +597,9 @@ fun CalendarHost(
onImport = {
importUri = it
importForceMany = true
importSession++
backupAfterImport = true
calendarsAfterImport = showCalendars
showBackup = false
showCalendars = false
},
@@ -73,19 +73,19 @@ import de.jeanlucmakiola.floret.identity.predictiveBack
@Composable
fun ImportScreen(
uri: Uri,
session: Int,
onClose: () -> Unit,
onOpenSingle: (EventForm) -> Unit,
forceMany: Boolean = false,
onManageCalendars: (() -> Unit)? = null,
// Key the VM by the file uri. This screen has no nav backstack, so an
// unkeyed hiltViewModel() resolves to the Activity's store and is retained
// across imports — its one-shot `load` guard would then show the *previous*
// file's parsed state on the next import (a second restore, export→restore,
// etc.). Keying per uri hands each distinct file a fresh VM (fresh Loading
// state), while the same uri (rotation) reuses it and holds the result.
viewModel: ImportViewModel = hiltViewModel(key = uri.toString()),
viewModel: ImportViewModel = hiltViewModel(),
) {
LaunchedEffect(uri) { viewModel.load(uri, forceMany) }
// hiltViewModel() resolves to the Activity's store and is retained across
// imports, so the reload is driven by [session] rather than by a fresh VM:
// keying per uri looked right but handed a *re-import of the same file* the
// previous run's finished state, importing nothing (#304). A rotation keeps
// the session and so keeps the result.
LaunchedEffect(session) { viewModel.load(uri, forceMany, session) }
val state by viewModel.state.collectAsStateWithLifecycle()
// A single event isn't shown here — it opens the create form for review.
@@ -16,6 +16,7 @@ import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
import de.jeanlucmakiola.calendula.domain.isEventTarget
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -69,18 +70,26 @@ class ImportViewModel @Inject constructor(
private val parser = IcsParser()
private val _state = MutableStateFlow<ImportUiState>(ImportUiState.Loading)
val state: StateFlow<ImportUiState> = _state.asStateFlow()
private var started = false
private var loadedSession: Int? = null
private var loadJob: Job? = null
/**
* Read + parse [uri] once; subsequent calls (recomposition) are ignored.
* Read + parse [uri] once per [session]; recomposition (and a rotation,
* which keeps the session) re-calls this and keeps the result. A new import
* of the *same* file is a new session, and has to parse and run again —
* keying on the uri alone showed the previous run's summary and imported
* nothing (#304).
*
* When [forceMany] is set (an in-app restore), a single-event file still goes
* through the bulk picker + summary rather than the prefilled create form —
* a restore is "bring back a backup", not "add this one event".
*/
fun load(uri: Uri, forceMany: Boolean = false) {
if (started) return
started = true
viewModelScope.launch {
fun load(uri: Uri, forceMany: Boolean = false, session: Int = 0) {
if (loadedSession == session) return
loadedSession = session
loadJob?.cancel()
_state.value = ImportUiState.Loading
loadJob = viewModelScope.launch {
val parsed = withContext(io) {
importer.readText(uri)?.let(parser::parse)
}