feat(settings): storage picker and export screen

The store picker and the export screen were the two frontend surfaces
the own-store work left unbuilt, so both backends shipped unreachable.
Settings gains a Storage section holding them: a full-screen picker over
Own / an installed external provider (dimmed when none is present, named
after the provider's own app), and an export screen with a per-list tick
and the two SAF destinations, a folder or a single zip. The picker asks
for the provider's runtime permission before writing the mode, so a
denial leaves the readable store in place instead of dropping the user on
the gate; a refusal is reported with a route to app settings.

Making the mode switchable at runtime had two consequences:

- reminders are armed off whichever store was active when they were
  scheduled, so a switch rebuilds the set. ReminderScheduler.sync() is
  now serialised — it is a read-modify-write over ScheduledReminderStore,
  and overlapping runs each wrote their own set as the whole truth
- the permission gate is the only screen an External user can reach once
  their provider app stops answering, so it offers the way back to our
  own store

ExportWriter no longer deletes a previous export before recreating it (a
failure in between lost both), lists the target directory once instead of
per document, and carries a typed ExportFailure so the screen can report
in the user's language rather than an exception message.
This commit is contained in:
2026-09-04 15:37:48 +02:00
parent 9ff6027e50
commit ec2e2eb59d
17 changed files with 845 additions and 79 deletions

View File

@@ -7,15 +7,16 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StartupGate import de.jeanlucmakiola.agendula.data.tasks.StartupGate
import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint import de.jeanlucmakiola.agendula.data.tasks.room.DatabaseCheckpoint
import de.jeanlucmakiola.floret.crash.CrashConfig import de.jeanlucmakiola.floret.crash.CrashConfig
import de.jeanlucmakiola.floret.crash.CrashReporter import de.jeanlucmakiola.floret.crash.CrashReporter
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
/** /**
* Application entry point. Registered as android:name=".AgendulaApp". Besides * Application entry point. Registered as android:name=".AgendulaApp". Besides
@@ -43,14 +44,23 @@ class AgendulaApp : Application() {
// Mirror the stored storage mode into ProviderResolver and import a // Mirror the stored storage mode into ProviderResolver and import a
// v0.3.x install's tasks, both before anything reads a store. // v0.3.x install's tasks, both before anything reads a store.
val startupGate = entryPoint.startupGate() val startupGate = entryPoint.startupGate()
val scope = entryPoint.applicationScope()
// An alarm is armed off whichever store was active when it was scheduled,
// so a switch has to rebuild the set. Armed only once startup's own
// null -> stored transition is past, which the launch sync below covers.
val started = AtomicBoolean(false)
entryPoint.providerResolver().onModeChanged {
if (started.get()) scope.launch { runCatching { scheduler.sync() } }
}
startupGate.start() startupGate.start()
ProcessLifecycleOwner.get().lifecycle.addObserver(entryPoint.databaseCheckpoint()) ProcessLifecycleOwner.get().lifecycle.addObserver(entryPoint.databaseCheckpoint())
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch { scope.launch {
// Wait for the stored mode and the import to land first. Rescheduling // Wait for the stored mode and the import to land first. Rescheduling
// alarms against whichever store autoMode happens to pick would arm // alarms against whichever store autoMode happens to pick would arm
// them off the wrong one — or off an empty one, mid-import. // them off the wrong one — or off an empty one, mid-import.
runCatching { runCatching {
startupGate.awaitReady() startupGate.awaitReady()
started.set(true)
scheduler.sync() scheduler.sync()
} }
} }
@@ -61,6 +71,10 @@ class AgendulaApp : Application() {
interface AppEntryPoint { interface AppEntryPoint {
fun reminderScheduler(): ReminderScheduler fun reminderScheduler(): ReminderScheduler
fun startupGate(): StartupGate fun startupGate(): StartupGate
fun providerResolver(): ProviderResolver
@ApplicationScope
fun applicationScope(): CoroutineScope
fun databaseCheckpoint(): DatabaseCheckpoint fun databaseCheckpoint(): DatabaseCheckpoint
} }
} }

View File

@@ -17,8 +17,23 @@ import javax.inject.Singleton
/** Where an export ended up, for the UI to report. */ /** Where an export ended up, for the UI to report. */
data class ExportResult(val fileCount: Int, val taskListNames: List<String>) data class ExportResult(val fileCount: Int, val taskListNames: List<String>)
/** The export could not be written. Carries a cause worth showing a user. */ /**
class ExportFailedException(message: String, cause: Throwable? = null) : IOException(message, cause) * Why an export failed, as a value rather than a message: the UI ships in eleven
* locales, so the wording has to come from a string resource.
*/
enum class ExportFailure {
FOLDER_UNAVAILABLE,
FOLDER_NOT_WRITABLE,
CANNOT_CREATE_FILE,
LOST_ACCESS,
WRITE_FAILED,
}
/** The export could not be written. */
class ExportFailedException(
val failure: ExportFailure,
cause: Throwable? = null,
) : IOException(failure.name, cause)
/** /**
* Writes [ExportDocument]s to a user-chosen location through the Storage Access * Writes [ExportDocument]s to a user-chosen location through the Storage Access
@@ -44,22 +59,30 @@ class ExportWriter @Inject constructor(
/** /**
* Writes every document into [treeUri], a directory the user picked. * Writes every document into [treeUri], a directory the user picked.
* *
* Overwrites same-named files rather than letting SAF append " (1)" — an * A same-named file is truncated and rewritten in place rather than deleted
* export is a snapshot, and silently accumulating `Groceries-3 (4).ics` makes * and recreated: SAF would otherwise append " (1)" and turn the folder into
* the folder useless as a backup. * an unusable pile of snapshots, and a delete that is not followed by a
* successful create loses the previous export outright.
*
* The directory is listed once. `DocumentFile.findFile` queries the whole
* tree per call, so looking each name up in the loop is one full
* cross-process directory scan per list.
*/ */
suspend fun writeToTree(treeUri: Uri, documents: List<ExportDocument>): ExportResult = suspend fun writeToTree(treeUri: Uri, documents: List<ExportDocument>): ExportResult =
withContext(io) { withContext(io) {
runCatching {
val tree = DocumentFile.fromTreeUri(context, treeUri) val tree = DocumentFile.fromTreeUri(context, treeUri)
?: throw ExportFailedException("Cannot open the chosen folder") ?: throw ExportFailedException(ExportFailure.FOLDER_UNAVAILABLE)
if (!tree.canWrite()) throw ExportFailedException("The chosen folder is not writable") if (!tree.canWrite()) throw ExportFailedException(ExportFailure.FOLDER_NOT_WRITABLE)
val existing = tree.listFiles().associateBy { it.name }
documents.forEach { document -> documents.forEach { document ->
tree.findFile(document.fileName)?.delete() val file = existing[document.fileName]
val file = tree.createFile(MIME_ICALENDAR, document.fileName) ?: tree.createFile(MIME_ICALENDAR, document.fileName)
?: throw ExportFailedException("Cannot create ${document.fileName}") ?: throw ExportFailedException(ExportFailure.CANNOT_CREATE_FILE)
write(file.uri, document.content) write(file.uri, document.content)
} }
}.getOrElse { throw asExportFailure(it) }
ExportResult(documents.size, documents.map { it.fileName }) ExportResult(documents.size, documents.map { it.fileName })
} }
@@ -80,7 +103,7 @@ class ExportWriter @Inject constructor(
zip.closeEntry() zip.closeEntry()
} }
} }
} ?: throw ExportFailedException("Cannot write to the chosen file") } ?: throw ExportFailedException(ExportFailure.WRITE_FAILED)
}.getOrElse { throw asExportFailure(it) } }.getOrElse { throw asExportFailure(it) }
ExportResult(documents.size, documents.map { it.fileName }) ExportResult(documents.size, documents.map { it.fileName })
} }
@@ -90,7 +113,7 @@ class ExportWriter @Inject constructor(
// "wt" truncates. Without it a shorter export leaves the tail of the // "wt" truncates. Without it a shorter export leaves the tail of the
// previous, longer one behind and produces a corrupt file. // previous, longer one behind and produces a corrupt file.
context.contentResolver.openOutputStream(target, "wt")?.use { it.write(bytes) } context.contentResolver.openOutputStream(target, "wt")?.use { it.write(bytes) }
?: throw ExportFailedException("Cannot write to the chosen file") ?: throw ExportFailedException(ExportFailure.WRITE_FAILED)
}.getOrElse { throw asExportFailure(it) } }.getOrElse { throw asExportFailure(it) }
} }
@@ -98,8 +121,8 @@ class ExportWriter @Inject constructor(
is ExportFailedException -> cause is ExportFailedException -> cause
// A SAF grant can be revoked between the picker and the write (the volume // A SAF grant can be revoked between the picker and the write (the volume
// was unmounted, the provider's process died, the user cleared the grant). // was unmounted, the provider's process died, the user cleared the grant).
is SecurityException -> ExportFailedException("Lost access to the chosen location", cause) is SecurityException -> ExportFailedException(ExportFailure.LOST_ACCESS, cause)
is IOException -> ExportFailedException(cause.message ?: "Could not write the export", cause) is IOException -> ExportFailedException(ExportFailure.WRITE_FAILED, cause)
else -> cause else -> cause
} }

View File

@@ -12,6 +12,8 @@ import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -20,8 +22,8 @@ import javax.inject.Singleton
* The self-scheduled due-reminder engine. Nothing else delivers task reminders — * The self-scheduled due-reminder engine. Nothing else delivers task reminders —
* not the platform, not a tasks provider — so Agendula reads upcoming due tasks * not the platform, not a tasks provider — so Agendula reads upcoming due tasks
* and arms one exact [AlarmManager] alarm each, within a rolling window. Re-run * and arms one exact [AlarmManager] alarm each, within a rolling window. Re-run
* on app start, on boot, and on an external provider change; it diffs against * on app start, on boot, on a store switch, and on an external provider change;
* [ScheduledReminderStore] so only changed alarms move. * it diffs against [ScheduledReminderStore] so only changed alarms move.
*/ */
@Singleton @Singleton
class ReminderScheduler @Inject constructor( class ReminderScheduler @Inject constructor(
@@ -32,19 +34,32 @@ class ReminderScheduler @Inject constructor(
private val providerResolver: ProviderResolver, private val providerResolver: ProviderResolver,
@IoDispatcher private val io: CoroutineDispatcher, @IoDispatcher private val io: CoroutineDispatcher,
) { ) {
suspend fun sync() = withContext(io) { private val syncLock = Mutex()
/**
* Diff the armed alarms against the store and move only what changed.
*
* Serialised: the diff is a read-modify-write over [ScheduledReminderStore],
* and callers overlap (a store switch fires this while the launch sync may
* still be running). Two interleaved runs would each write their own set as
* the whole truth, leaving the other's alarms armed but unrecorded — never
* cancelled, and firing against the wrong store's task ids.
*/
suspend fun sync() = withContext(io) { syncLock.withLock { syncLocked() } }
private suspend fun syncLocked() {
val settings = settingsPrefs.settings.first() val settings = settingsPrefs.settings.first()
// Gate on whether the store is readable, not on whether a provider // Gate on whether the store is readable, not on whether a provider
// resolves: our own store deliberately resolves to no provider, so the // resolves: our own store deliberately resolves to no provider, so the
// latter clears every reminder in the default mode. // latter clears every reminder in the default mode.
if (!settings.remindersEnabled || !providerResolver.canReadStore()) { if (!settings.remindersEnabled || !providerResolver.canReadStore()) {
clearAll() clearAll()
return@withContext return
} }
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val horizon = now + WINDOW_MS val horizon = now + WINDOW_MS
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) } val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
.getOrElse { return@withContext } .getOrElse { return }
// One reminder per *occurrence*: a recurring series yields a row per // One reminder per *occurrence*: a recurring series yields a row per
// occurrence, all sharing a taskId, so this is a Set rather than a // occurrence, all sharing a taskId, so this is a Set rather than a

View File

@@ -23,6 +23,9 @@ interface ProviderEnvironment {
/** Whether this app currently holds [permission]. */ /** Whether this app currently holds [permission]. */
fun isGranted(permission: String): Boolean fun isGranted(permission: String): Boolean
/** [packageName]'s own app name, or null when it cannot be read. */
fun appLabel(packageName: String): String?
} }
@Singleton @Singleton
@@ -35,4 +38,9 @@ class AndroidProviderEnvironment @Inject constructor(
override fun isGranted(permission: String): Boolean = override fun isGranted(permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
override fun appLabel(packageName: String): String? = runCatching {
val pm = context.packageManager
pm.getApplicationLabel(pm.getApplicationInfo(packageName, 0)).toString()
}.getOrNull()
} }

View File

@@ -10,19 +10,17 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.ui.common.OnResume
import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
import de.jeanlucmakiola.agendula.ui.navigation.AgendulaNavHost import de.jeanlucmakiola.agendula.ui.navigation.AgendulaNavHost
import de.jeanlucmakiola.agendula.ui.permission.PermissionViewModel import de.jeanlucmakiola.agendula.ui.permission.PermissionViewModel
@@ -46,20 +44,20 @@ fun RootScreen(
// Re-check on every resume, not just after the in-app request: the user may // Re-check on every resume, not just after the in-app request: the user may
// have granted the permission (or installed a provider) in system Settings and // have granted the permission (or installed a provider) in system Settings and
// come back, and otherwise the gate would hold until the process restarts. // come back, and otherwise the gate would hold until the process restarts.
val lifecycleOwner = LocalLifecycleOwner.current OnResume { permissionViewModel.refresh() }
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event -> // Neither gate can show in OWN mode (that store is always READY), so the way
if (event == Lifecycle.Event.ON_RESUME) permissionViewModel.refresh() // out of one is always our own store. Without it a user whose provider app
} // went away is held on this screen with Settings behind it.
lifecycleOwner.lifecycle.addObserver(observer) val fallback = stringResource(R.string.onboarding_use_own_store)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
when (permission.status) { when (permission.status) {
ProviderStatus.NO_PROVIDER -> Gate( ProviderStatus.NO_PROVIDER -> Gate(
modifier = modifier, modifier = modifier,
title = stringResource(R.string.onboarding_no_provider_title), title = stringResource(R.string.onboarding_no_provider_title),
body = stringResource(R.string.onboarding_no_provider_body), body = stringResource(R.string.onboarding_no_provider_body),
secondaryAction = fallback,
onSecondaryAction = permissionViewModel::useOwnStore,
) )
ProviderStatus.NEEDS_PERMISSION -> Gate( ProviderStatus.NEEDS_PERMISSION -> Gate(
modifier = modifier, modifier = modifier,
@@ -67,6 +65,8 @@ fun RootScreen(
body = stringResource(R.string.onboarding_permission_body), body = stringResource(R.string.onboarding_permission_body),
action = stringResource(R.string.onboarding_permission_button), action = stringResource(R.string.onboarding_permission_button),
onAction = { launcher.launch(permission.permissionsToRequest.toTypedArray()) }, onAction = { launcher.launch(permission.permissionsToRequest.toTypedArray()) },
secondaryAction = fallback,
onSecondaryAction = permissionViewModel::useOwnStore,
) )
ProviderStatus.READY -> ReadyGate(modifier = modifier) ProviderStatus.READY -> ReadyGate(modifier = modifier)
} }
@@ -103,6 +103,8 @@ private fun Gate(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
action: String? = null, action: String? = null,
onAction: () -> Unit = {}, onAction: () -> Unit = {},
secondaryAction: String? = null,
onSecondaryAction: () -> Unit = {},
) { ) {
Column( Column(
modifier = modifier.fillMaxSize().padding(24.dp), modifier = modifier.fillMaxSize().padding(24.dp),
@@ -112,5 +114,6 @@ private fun Gate(
Text(title, style = MaterialTheme.typography.headlineSmall) Text(title, style = MaterialTheme.typography.headlineSmall)
Text(body, style = MaterialTheme.typography.bodyMedium) Text(body, style = MaterialTheme.typography.bodyMedium)
if (action != null) Button(onClick = onAction) { Text(action) } if (action != null) Button(onClick = onAction) { Text(action) }
if (secondaryAction != null) TextButton(onClick = onSecondaryAction) { Text(secondaryAction) }
} }
} }

View File

@@ -0,0 +1,29 @@
package de.jeanlucmakiola.agendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
/**
* Runs [block] on every `ON_RESUME`.
*
* For state the app cannot observe because it is granted, revoked or installed
* outside it — a runtime permission, an exact-alarm allowance, a provider app —
* which otherwise stays stale until the process restarts.
*/
@Composable
fun OnResume(block: () -> Unit) {
val current by rememberUpdatedState(block)
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) current()
}
lifecycle.addObserver(observer)
onDispose { lifecycle.removeObserver(observer) }
}
}

View File

@@ -0,0 +1,182 @@
package de.jeanlucmakiola.agendula.ui.export
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Circle
import androidx.compose.material.icons.rounded.Folder
import androidx.compose.material.icons.rounded.FolderZip
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.export.ExportFailure
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.components.positionOf
private const val ZIP_MIME = "application/zip"
private const val ZIP_NAME = "agendula-tasks.zip"
/**
* Export the task lists as iCalendar. Which lists go out is a per-list tick; the
* destination is a folder or a single zip, both picked through SAF so the app
* needs no storage permission.
*/
@Composable
fun ExportScreen(
onBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: ExportViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val dark = isSystemInDarkTheme()
val folderLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree(),
) { uri -> uri?.let(viewModel::exportToFolder) }
val zipLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument(ZIP_MIME),
) { uri -> uri?.let(viewModel::exportToZip) }
val canExport = !state.running && state.selectedCount > 0
CollapsingScaffold(
title = stringResource(R.string.settings_export),
onBack = onBack,
modifier = modifier,
) {
Text(
text = stringResource(R.string.export_hint),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
Spacer(Modifier.height(16.dp))
if (state.lists.isEmpty()) {
GroupedRow(
title = stringResource(R.string.export_no_lists),
position = Position.Alone,
dimmed = true,
)
} else {
state.lists.forEachIndexed { index, list ->
val selected = state.isSelected(list.id)
GroupedRow(
title = list.name,
// The account only says something when it isn't the device itself.
summary = list.accountName.takeIf { !list.isLocal },
position = positionOf(index, state.lists.size),
leading = {
Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark))
},
trailing = {
Checkbox(checked = selected, onCheckedChange = { viewModel.toggle(list.id) })
},
onClick = { viewModel.toggle(list.id) },
)
}
}
Spacer(Modifier.height(24.dp))
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = { folderLauncher.launch(null) },
enabled = canExport,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Rounded.Folder, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.export_to_folder))
}
OutlinedButton(
onClick = { zipLauncher.launch(ZIP_NAME) },
enabled = canExport,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Rounded.FolderZip, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.export_to_zip))
}
}
Spacer(Modifier.height(16.dp))
ExportStatus(state = state)
Spacer(Modifier.height(24.dp))
}
}
/** The running spinner, then whatever the last export ended as — it stays put. */
@Composable
private fun ExportStatus(state: ExportUiState) {
val outcome = state.outcome
when {
state.running -> Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator(Modifier.size(18.dp))
Text(
text = stringResource(R.string.export_running),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
outcome is ExportOutcome.Success -> StatusText(
text = pluralStringResource(R.plurals.export_done, outcome.fileCount, outcome.fileCount),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
outcome is ExportOutcome.Failure -> StatusText(
text = stringResource(failureMessage(outcome.reason)),
color = MaterialTheme.colorScheme.error,
)
}
}
private fun failureMessage(reason: ExportFailure): Int = when (reason) {
ExportFailure.FOLDER_UNAVAILABLE -> R.string.export_failed_folder
ExportFailure.FOLDER_NOT_WRITABLE -> R.string.export_failed_read_only
ExportFailure.CANNOT_CREATE_FILE -> R.string.export_failed_create
ExportFailure.LOST_ACCESS -> R.string.export_failed_access
ExportFailure.WRITE_FAILED -> R.string.export_failed
}
@Composable
private fun StatusText(text: String, color: Color) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = color,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
)
}

View File

@@ -0,0 +1,122 @@
package de.jeanlucmakiola.agendula.ui.export
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.export.ExportFailedException
import de.jeanlucmakiola.agendula.data.export.ExportFailure
import de.jeanlucmakiola.agendula.data.export.ExportResult
import de.jeanlucmakiola.agendula.data.export.ExportWriter
import de.jeanlucmakiola.agendula.data.export.TaskExporter
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.export.ExportDocument
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.coroutines.cancellation.CancellationException
/** How the last export ended, kept on screen rather than flashed past. */
sealed interface ExportOutcome {
data class Success(val fileCount: Int) : ExportOutcome
data class Failure(val reason: ExportFailure) : ExportOutcome
}
data class ExportUiState(
val lists: List<TaskList> = emptyList(),
/** Lists the user has ticked *off*; everything else is included. */
val excluded: Set<Long> = emptySet(),
val running: Boolean = false,
val outcome: ExportOutcome? = null,
) {
fun isSelected(listId: Long): Boolean = listId !in excluded
val selectedCount: Int get() = lists.count { isSelected(it.id) }
}
/**
* Drives the export screen. Holds the selection as an exclusion set so a list
* that appears while the screen is open is exported too — the natural reading of
* "everything, minus what I unticked".
*/
@HiltViewModel
class ExportViewModel @Inject constructor(
repository: TasksRepository,
resolver: ProviderResolver,
private val exporter: TaskExporter,
private val writer: ExportWriter,
) : ViewModel() {
private val excluded = MutableStateFlow(emptySet<Long>())
private val running = MutableStateFlow(false)
private val outcome = MutableStateFlow<ExportOutcome?>(null)
private var exportJob: Job? = null
// List ids are per-store, and Settings can switch stores with this ViewModel
// still alive — so the selection, the receipt and a write already addressing
// the old store's lists all go with it.
private val modeHandle = resolver.onModeChanged {
exportJob?.cancel()
excluded.value = emptySet()
outcome.value = null
}
override fun onCleared() {
modeHandle.close()
}
val state: StateFlow<ExportUiState> =
combine(
repository.taskLists().recoveringFromProviderFailure { emptyList() },
excluded,
running,
outcome,
) { lists, excluded, running, outcome ->
ExportUiState(lists, excluded, running, outcome)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ExportUiState())
fun toggle(listId: Long) = excluded.update { current ->
if (listId in current) current - listId else current + listId
}
/** Writes one `.ics` per list into a folder the user picked through SAF. */
fun exportToFolder(tree: Uri) = export { documents -> writer.writeToTree(tree, documents) }
/** Writes every list into a single zip the user named through SAF. */
fun exportToZip(target: Uri) = export { documents -> writer.writeZip(target, documents) }
private fun export(write: suspend (List<ExportDocument>) -> ExportResult) {
if (running.value) return
running.value = true
outcome.value = null
exportJob = viewModelScope.launch {
// Null means "every list" to the exporter, and is what an untouched
// screen should send: the flow may not have emitted a list yet.
val selection = excluded.value.takeIf { it.isNotEmpty() }
?.let { skipped -> state.value.lists.map { it.id }.toSet() - skipped }
try {
val result = write(exporter.export(selection))
outcome.value = ExportOutcome.Success(result.fileCount)
} catch (cancelled: CancellationException) {
// Leaving the screen mid-write is not a failed export.
throw cancelled
} catch (error: Exception) {
outcome.value = ExportOutcome.Failure(
(error as? ExportFailedException)?.failure ?: ExportFailure.WRITE_FAILED,
)
} finally {
running.value = false
}
}
}
}

View File

@@ -1,13 +1,25 @@
package de.jeanlucmakiola.agendula.ui.permission package de.jeanlucmakiola.agendula.ui.permission
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
data class PermissionUiState( data class PermissionUiState(
@@ -21,25 +33,48 @@ data class PermissionUiState(
* The Composable owns the actual permission-launcher and store intents; this VM * The Composable owns the actual permission-launcher and store intents; this VM
* supplies the [status] and the exact permission strings to ask for. * supplies the [status] and the exact permission strings to ask for.
* *
* In the default Local mode this gate never appears at all — Agendula's own * In the default Own mode this gate never appears at all — the store is our own
* provider ships in the APK and is reached same-uid, so there is nothing to * Room database, so there is nothing to install and nothing to grant. It exists
* install and nothing to grant. It exists for External mode. * for External mode, which also makes it the only screen an External user can
* reach once their provider app stops answering: hence [useOwnStore].
*/ */
@HiltViewModel @HiltViewModel
class PermissionViewModel @Inject constructor( class PermissionViewModel @Inject constructor(
private val repository: TasksRepository, private val repository: TasksRepository,
private val providerResolver: ProviderResolver, private val providerResolver: ProviderResolver,
private val prefs: SettingsPrefs,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(PermissionUiState()) private val refreshes = MutableStateFlow(0)
val state: StateFlow<PermissionUiState> = _state.asStateFlow()
init { refresh() } // Re-evaluated when the *resolver's* mode lands, not when the preference is
// written: anything read in between still answers for the store we just left.
// Conflated, as everywhere else this signal is bridged: only the latest mode
// matters, and a full buffer drops it rather than the ones it supersedes.
private val modeChanges: Flow<Unit> = callbackFlow {
trySend(Unit)
val handle = providerResolver.onModeChanged { trySend(Unit) }
awaitClose { handle.close() }
}.buffer(Channel.CONFLATED)
val state: StateFlow<PermissionUiState> =
combine(refreshes, modeChanges) { _, _ -> currentState() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), currentState())
/** Re-read provider + permission state (call after returning from a request). */ /** Re-read provider + permission state (call after returning from a request). */
fun refresh() { fun refresh() = refreshes.update { it + 1 }
/**
* Leave a store this device can no longer read. The provider app can be
* uninstalled, or its permission revoked, after External was chosen — and the
* gate is then the only screen reachable, Settings included. Our own store
* always reads, so it is the way out.
*/
fun useOwnStore() = viewModelScope.launch { prefs.setStorageMode(StorageMode.OWN) }
private fun currentState(): PermissionUiState {
val provider = providerResolver.resolve() val provider = providerResolver.resolve()
_state.value = PermissionUiState( return PermissionUiState(
status = repository.providerStatus(), status = repository.providerStatus(),
// Null in OWN mode, where there is no provider and nothing to grant. // Null in OWN mode, where there is no provider and nothing to grant.
permissionsToRequest = provider permissionsToRequest = provider

View File

@@ -49,6 +49,7 @@ import androidx.compose.material.icons.rounded.AccountTree
import androidx.compose.material.icons.rounded.Circle import androidx.compose.material.icons.rounded.Circle
import androidx.compose.material.icons.rounded.Flag import androidx.compose.material.icons.rounded.Flag
import androidx.compose.material.icons.rounded.Percent import androidx.compose.material.icons.rounded.Percent
import androidx.compose.material.icons.rounded.Storage
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -56,7 +57,6 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -75,13 +75,11 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.ui.export.ExportScreen
import de.jeanlucmakiola.floret.components.AboutCard import de.jeanlucmakiola.floret.components.AboutCard
import de.jeanlucmakiola.floret.components.AboutLink import de.jeanlucmakiola.floret.components.AboutLink
import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.CollapsingScaffold
@@ -96,10 +94,22 @@ import de.jeanlucmakiola.floret.locale.AppLanguage
import de.jeanlucmakiola.floret.identity.expandEnter import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.reminders.ReminderOverride import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
import de.jeanlucmakiola.agendula.ui.common.OnResume
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
/** The settings sub-screens reached from the hub's category rows. */ /** The settings sub-screens reached from the hub's category rows. */
private enum class SettingsSection { Appearance, TaskForm, Reminders } private enum class SettingsSection {
Appearance,
TaskForm,
Reminders,
Storage,
Export,
;
/** Where back goes: Export is opened from Storage, not from the hub. */
val parent: SettingsSection?
get() = if (this == Export) Storage else null
}
/** /**
* Token-based accent for a leading icon chip (container / on-container pair), * Token-based accent for a leading icon chip (container / on-container pair),
@@ -125,7 +135,7 @@ fun SettingsScreen(
// Inside a sub-screen, system back (button or gesture) returns to the hub // Inside a sub-screen, system back (button or gesture) returns to the hub
// rather than popping the whole Settings destination to the lists overview. // rather than popping the whole Settings destination to the lists overview.
BackHandler(enabled = section != null) { section = null } BackHandler(enabled = section != null) { section = section?.parent }
Box( Box(
modifier = modifier modifier = modifier
@@ -143,6 +153,19 @@ fun SettingsScreen(
SlideInSection(visible = section == SettingsSection.Reminders) { SlideInSection(visible = section == SettingsSection.Reminders) {
RemindersScreen(state = state, viewModel = viewModel, onBack = { section = null }) RemindersScreen(state = state, viewModel = viewModel, onBack = { section = null })
} }
// Storage stays composed under Export, so the deeper screen slides over it.
val storageOpen = section == SettingsSection.Storage ||
section?.parent == SettingsSection.Storage
SlideInSection(visible = storageOpen) {
StorageScreen(
viewModel = viewModel,
onOpenExport = { section = SettingsSection.Export },
onBack = { section = null },
)
}
SlideInSection(visible = section == SettingsSection.Export) {
ExportScreen(onBack = { section = SettingsSection.Storage })
}
} }
} }
@@ -212,6 +235,13 @@ private fun SettingsHub(
leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) }, leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) },
onClick = { onOpenSection(SettingsSection.Reminders) }, onClick = { onOpenSection(SettingsSection.Reminders) },
) )
GroupedRow(
title = stringResource(R.string.settings_section_storage),
summary = stringResource(R.string.settings_storage_subtitle),
position = Position.Middle,
leading = { CategoryIcon(Icons.Rounded.Storage, ChipAccent.Neutral) },
onClick = { onOpenSection(SettingsSection.Storage) },
)
LanguageRow(position = Position.Middle) LanguageRow(position = Position.Middle)
ReportProblemRow(position = Position.Bottom) ReportProblemRow(position = Position.Bottom)
@@ -670,16 +700,11 @@ private fun rememberExactAlarmAllowed(context: Context): Boolean {
context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms(), context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms(),
) )
} }
val lifecycle = LocalLifecycleOwner.current.lifecycle OnResume {
DisposableEffect(lifecycle) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val obs = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
allowed = context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms() allowed = context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms()
} }
} }
lifecycle.addObserver(obs)
onDispose { lifecycle.removeObserver(obs) }
}
return allowed return allowed
} }

View File

@@ -3,18 +3,27 @@ package de.jeanlucmakiola.agendula.ui.settings
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.prefs.Settings import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TaskProvider
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.floret.reminders.ReminderOverride import de.jeanlucmakiola.floret.reminders.ReminderOverride
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -23,6 +32,22 @@ data class SettingsUiState(
val lists: List<TaskList> = emptyList(), val lists: List<TaskList> = emptyList(),
) )
/**
* The storage half of Settings: which store is active, and what picking the
* other one would mean on this device.
*
* Kept apart from [SettingsUiState] because that one is collected for the whole
* Activity lifetime to drive the theme, and re-probing PackageManager on every
* theme emission would be work for nothing.
*/
data class StorageUiState(
val mode: StorageMode,
/** The external provider installed here, or null when there is none to pick. */
val external: TaskProvider? = null,
/** That provider's own app name, for a row that names what it is switching to. */
val externalLabel: String? = null,
)
/** /**
* Drives both the Settings screen and the app theme (MainActivity collects the * Drives both the Settings screen and the app theme (MainActivity collects the
* same instance), so a theme change applies app-wide at once. * same instance), so a theme change applies app-wide at once.
@@ -30,6 +55,9 @@ data class SettingsUiState(
@HiltViewModel @HiltViewModel
class SettingsViewModel @Inject constructor( class SettingsViewModel @Inject constructor(
private val prefs: SettingsPrefs, private val prefs: SettingsPrefs,
private val resolver: ProviderResolver,
private val environment: ProviderEnvironment,
@IoDispatcher io: CoroutineDispatcher,
repository: TasksRepository, repository: TasksRepository,
) : ViewModel() { ) : ViewModel() {
@@ -44,6 +72,31 @@ class SettingsViewModel @Inject constructor(
SettingsUiState(settings, lists) SettingsUiState(settings, lists)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState()) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState())
// Bumped to re-probe the device; installing a provider or granting its
// permission happens outside the app, so nothing else would emit.
private val providerProbe = MutableStateFlow(0)
// Null until the first emission lands: the mode comes from DataStore and the
// rest from PackageManager, off the main thread, so any seeded default would
// name the wrong store for the first frames. flowOn, because every field here
// costs a PackageManager lookup or a permission check.
val storage: StateFlow<StorageUiState?> =
combine(prefs.storageMode, providerProbe) { stored, _ ->
val external = resolver.resolveExternal()
StorageUiState(
// No stored choice is the normal state; show what autoMode resolves
// to rather than a default that may not be the store in use.
mode = stored ?: resolver.autoMode(),
external = external,
externalLabel = external?.packageName?.let(environment::appLabel),
)
}.flowOn(io).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
/** Re-read the device's provider state, after a permission request or a resume. */
fun refreshStorage() = providerProbe.update { it + 1 }
fun setStorageMode(mode: StorageMode) = viewModelScope.launch { prefs.setStorageMode(mode) }
fun setThemeMode(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) } fun setThemeMode(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) }
fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) } fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) }
fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) } fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) }

View File

@@ -0,0 +1,203 @@
package de.jeanlucmakiola.agendula.ui.settings
import android.content.Context
import android.content.Intent
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Apps
import androidx.compose.material.icons.rounded.PhoneAndroid
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TaskProvider
import de.jeanlucmakiola.agendula.ui.common.OnResume
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
/**
* Where the tasks live: the store picker the resolver's `autoMode()` has always
* assumed, plus the way out of a store that lives in our own private storage.
*/
@Composable
internal fun StorageScreen(
viewModel: SettingsViewModel,
onOpenExport: () -> Unit,
onBack: () -> Unit,
) {
val context = LocalContext.current
val storage by viewModel.storage.collectAsStateWithLifecycle()
var showPicker by remember { mutableStateOf(false) }
var denied by remember { mutableStateOf(false) }
// The mode is committed only once the grant is in — switching first drops the
// user on the app-wide permission gate.
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions(),
) { grants ->
viewModel.refreshStorage()
val granted = grants.isNotEmpty() && grants.values.all { it }
denied = !granted
if (granted) viewModel.setStorageMode(StorageMode.EXTERNAL)
}
// A provider can be installed, or its permission revoked, while we're away.
// Deliberately does not clear [denied]: this fires on returning from the
// permission dialog too, and would wipe the refusal before it is read.
OnResume { viewModel.refreshStorage() }
CollapsingScaffold(title = stringResource(R.string.settings_section_storage), onBack = onBack) {
GroupedRow(
title = stringResource(R.string.settings_task_store),
summary = storage?.let { storeLabel(it) },
position = Position.Top,
// Nothing to pick until the stored mode has landed; opening the picker
// on the seeded state would offer the wrong store as the current one.
onClick = storage?.let {
{
denied = false
showPicker = true
}
},
)
GroupedRow(
title = stringResource(R.string.settings_export),
summary = stringResource(R.string.settings_export_hint),
position = Position.Bottom,
onClick = onOpenExport,
)
if (denied) {
Spacer(Modifier.height(16.dp))
GroupedRow(
title = stringResource(R.string.settings_store_permission_denied),
summary = stringResource(R.string.settings_store_permission_denied_hint),
position = Position.Alone,
onClick = { context.openAppSettings() },
)
}
}
storage?.let { state ->
if (showPicker) {
StorePicker(
storage = state,
onSelect = { mode ->
val external = state.external
if (mode == StorageMode.EXTERNAL && external != null) {
// Asked even when the grant looks held: an already-granted
// request returns at once, a stale belief would strand them.
permissionLauncher.launch(
arrayOf(external.readPermission, external.writePermission),
)
} else {
viewModel.setStorageMode(mode)
}
},
onDismiss = { showPicker = false },
)
}
}
}
/**
* The two stores, as rows. External is offered only when a provider is actually
* installed — dimmed and inert otherwise, because a mode with nothing behind it
* empties the app.
*/
@Composable
private fun StorePicker(
storage: StorageUiState,
onSelect: (StorageMode) -> Unit,
onDismiss: () -> Unit,
) {
FullScreenPicker(title = stringResource(R.string.settings_task_store), onDismiss = onDismiss) {
Text(
text = stringResource(R.string.settings_task_store_hint),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
Spacer(Modifier.height(8.dp))
val select: (StorageMode) -> Unit = { chosen ->
onSelect(chosen)
onDismiss()
}
val external = storage.external
GroupedRow(
title = stringResource(R.string.settings_store_own),
summary = stringResource(R.string.settings_store_own_hint),
position = Position.Top,
selected = storage.mode == StorageMode.OWN,
leading = { Icon(Icons.Rounded.PhoneAndroid, contentDescription = null) },
trailing = if (storage.mode == StorageMode.OWN) {
{ SelectedCheck() }
} else {
null
},
onClick = { select(StorageMode.OWN) },
)
GroupedRow(
title = externalTitle(external, storage.externalLabel),
summary = stringResource(
if (external == null) {
R.string.settings_store_external_missing
} else {
R.string.settings_store_external_hint
},
),
position = Position.Bottom,
selected = storage.mode == StorageMode.EXTERNAL,
dimmed = external == null,
leading = { Icon(Icons.Rounded.Apps, contentDescription = null) },
trailing = if (storage.mode == StorageMode.EXTERNAL) {
{ SelectedCheck() }
} else {
null
},
onClick = if (external != null) ({ select(StorageMode.EXTERNAL) }) else null,
)
Spacer(Modifier.height(24.dp))
}
}
/** The active store, named the way the picker names it. */
@Composable
private fun storeLabel(storage: StorageUiState): String = when (storage.mode) {
StorageMode.OWN -> stringResource(R.string.settings_store_own)
StorageMode.EXTERNAL -> externalTitle(storage.external, storage.externalLabel)
}
/** The provider's own app name, its authority, or the generic wording. */
@Composable
private fun externalTitle(provider: TaskProvider?, label: String?): String =
label ?: provider?.authority ?: stringResource(R.string.settings_store_external)
private fun Context.openAppSettings() {
runCatching {
startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, "package:$packageName".toUri()),
)
}
}

View File

@@ -246,6 +246,37 @@
<string name="settings_bottom_add_bar">Bottom quick-add bar</string> <string name="settings_bottom_add_bar">Bottom quick-add bar</string>
<string name="settings_bottom_add_bar_hint">Add tasks from a bar pinned to the bottom of a list, instead of the floating button</string> <string name="settings_bottom_add_bar_hint">Add tasks from a bar pinned to the bottom of a list, instead of the floating button</string>
<string name="onboarding_use_own_store">Use this device\'s storage instead</string>
<!-- Storage and export -->
<string name="settings_section_storage">Storage</string>
<string name="settings_storage_subtitle">Where tasks are kept, and export</string>
<string name="settings_task_store">Task store</string>
<string name="settings_task_store_hint">Each store keeps its own tasks. Switching does not move them across — export first if you want a copy.</string>
<string name="settings_store_own">On this device</string>
<string name="settings_store_own_hint">Agendula\'s own storage. Nothing else to install.</string>
<string name="settings_store_external">Another task app</string>
<string name="settings_store_external_hint">Share tasks with the app that syncs them</string>
<string name="settings_store_external_missing">No compatible task app is installed</string>
<string name="settings_store_permission_denied">Permission denied</string>
<string name="settings_store_permission_denied_hint">The other app\'s tasks stay unreachable until you allow access. Tap to open app settings.</string>
<string name="settings_export">Export tasks</string>
<string name="settings_export_hint">Save your lists as iCalendar files</string>
<string name="export_hint">One .ics file per list, readable by other task and calendar apps. The ticked lists go to a folder you pick, or into a single zip.</string>
<string name="export_no_lists">No lists to export</string>
<string name="export_to_folder">Save to a folder</string>
<string name="export_to_zip">Save as a zip file</string>
<string name="export_running">Exporting…</string>
<plurals name="export_done">
<item quantity="one">Exported %1$d list</item>
<item quantity="other">Exported %1$d lists</item>
</plurals>
<string name="export_failed">The export could not be written</string>
<string name="export_failed_folder">The chosen folder could not be opened</string>
<string name="export_failed_read_only">The chosen folder is not writable</string>
<string name="export_failed_create">A file could not be created in the chosen folder</string>
<string name="export_failed_access">Access to the chosen location was lost</string>
<!-- Reminder lead times (custom amounts) --> <!-- Reminder lead times (custom amounts) -->
<plurals name="reminder_minutes"> <plurals name="reminder_minutes">
<item quantity="one">%1$d minute before</item> <item quantity="one">%1$d minute before</item>

View File

@@ -23,6 +23,7 @@ class ProviderResolverTest {
) : ProviderEnvironment { ) : ProviderEnvironment {
override fun packageDeclaring(authority: String): String? = installed[authority] override fun packageDeclaring(authority: String): String? = installed[authority]
override fun isGranted(permission: String): Boolean = permission in granted override fun isGranted(permission: String): Boolean = permission in granted
override fun appLabel(packageName: String): String? = packageName
} }
private val openTasks = ProviderResolver.EXTERNAL_CANDIDATES.first { it.authority == "org.dmfs.tasks" } private val openTasks = ProviderResolver.EXTERNAL_CANDIDATES.first { it.authority == "org.dmfs.tasks" }

View File

@@ -108,7 +108,7 @@ postscript in [`STORAGE-DECISION.md`](STORAGE-DECISION.md). Package root
| `data/prefs/` | `SettingsPrefs` (DataStore). | | `data/prefs/` | `SettingsPrefs` (DataStore). |
| `data/di/` | `DataModule` (binds + provides), `Qualifiers` (`@IoDispatcher`, `@ApplicationScope`). | | `data/di/` | `DataModule` (binds + provides), `Qualifiers` (`@IoDispatcher`, `@ApplicationScope`). |
| `data/demo/` | `DemoSeeder` (debug-only sample data). | | `data/demo/` | `DemoSeeder` (debug-only sample data). |
| `ui/` | `theme/`, `common/` (ListChip, PriorityChip, reminder pickers), `navigation/` (`AgendulaNavHost` + `Dest`), `lists/`, `tasklist/`, `detail/`, `edit/`, `settings/`, `permission/` (each a screen + ViewModel + UiState), `crash/`, `RootScreen`. | | `ui/` | `theme/`, `common/` (ListChip, PriorityChip, reminder pickers), `navigation/` (`AgendulaNavHost` + `Dest`), `lists/`, `tasklist/`, `detail/`, `edit/`, `settings/` (hub + sub-screens, `StorageScreen` among them), `export/`, `permission/` (each a screen + ViewModel + UiState), `crash/`, `RootScreen`. |
| root | `AgendulaApp` (Hilt app), `MainActivity`. | | root | `AgendulaApp` (Hilt app), `MainActivity`. |
--- ---
@@ -447,10 +447,21 @@ fallback in `ui/theme/`). Each screen area (`lists`, `tasklist`, `detail`,
`RootScreen` is the entry composable: it gates on `ProviderStatus` `RootScreen` is the entry composable: it gates on `ProviderStatus`
(`NO_PROVIDER` / `NEEDS_PERMISSION` → onboarding `Gate`; `READY` → (`NO_PROVIDER` / `NEEDS_PERMISSION` → onboarding `Gate`; `READY` →
`AgendulaNavHost`). In `OWN` mode the status is always `READY`, so that gate is `AgendulaNavHost`). In `OWN` mode the status is always `READY`, so that gate is
only ever seen in External mode. Routes are the `Dest` table in only ever seen in External mode — and it offers a way back to our own store,
`ui/navigation/` (lists → task list → detail / edit, plus settings). Follow the because it is the only screen an External user can reach once their provider app
`material-3` skill for component choices (M3 `ListItem` rows, expressive stops answering. Routes are the `Dest` table in `ui/navigation/` (lists → task
checkbox/FAB/swipe motion). list → detail / edit, plus settings). Follow the `material-3` skill for component
choices (M3 `ListItem` rows, expressive checkbox/FAB/swipe motion).
Settings is a hub of sliding sub-screens rather than routes; **Storage** is the
one with teeth. It holds the §4.1 store picker — which asks for an external
provider's runtime permission *before* writing the mode, so a denial leaves the
readable store in place instead of stranding the user on the gate — and the
export screen (`ui/export/`, one `.ics` per ticked list, written through SAF to a
folder or a single zip). Because the mode is now switchable while the process
lives, `AgendulaApp` re-arms reminders on `ProviderResolver.onModeChanged`: an
alarm is scheduled off whichever store was active at the time, so the whole set
has to be rebuilt against the new one.
--- ---

View File

@@ -19,10 +19,11 @@ iCalendar has landed, and the Material 3 Expressive UI is built through **M5**:
lists → task list (swipe gestures, inline add, smart-list section headers) → lists → task list (swipe gestures, inline add, smart-list section headers) →
detail / edit with full CRUD, date-time pickers, priority, percent-complete, detail / edit with full CRUD, date-time pickers, priority, percent-complete,
conflict-safe saves, per-task reminders, and subtask create + reparent — plus a conflict-safe saves, per-task reminders, and subtask create + reparent — plus a
one-time reminder onboarding step and a Settings screen. Remaining work is one-time reminder onboarding step and a Settings screen. The frontend surfaces
hardening the new store (`OWN-STORE.md` phase 6), the **frontend surfaces for for the new store have landed too: a Settings **Storage** section with the
what has landed** (a storage-mode picker, an export screen), then M6 (Glance store picker and an **export screen**. Remaining work is verifying all of it on
widget, translations, F-Droid release) and the sync adapter. a device, then M6 (Glance widget, translations, F-Droid release) and the sync
adapter.
--- ---
@@ -147,8 +148,17 @@ what Posture B means (our own store, coexisting with everything — *not* squatt
- ✅ Export to iCalendar (step 3) — a v1 feature now that own-mode data lives - ✅ Export to iCalendar (step 3) — a v1 feature now that own-mode data lives
only in our app's private storage. One `.ics` per list, to a folder or a zip, only in our app's private storage. One `.ics` per list, to a folder or a zip,
via SAF. Backend only. via SAF. Backend only.
- **Frontend surfaces for the above**a storage-mode picker in Settings and - **Frontend surfaces for the above**Settings gained a **Storage** section
an export screen. The backend is done and unused until these exist. holding both: a full-screen store picker (Own / an installed external provider,
which is dimmed when none is present) that asks for the provider's runtime
permission *before* committing the switch, and an export screen with a per-list
tick and the two SAF destinations, a folder or a single zip. Two consequences
of the mode becoming switchable at runtime came with it: reminders are re-armed
against the new store on every switch (`AgendulaApp` listens on
`ProviderResolver.onModeChanged`; previously only a restart, a boot or an edit
resynced them), and the permission gate offers a way back to our own store —
otherwise a user whose provider app went away is held on a gate with Settings
behind it.
- ⬜ File the DAVx5 issue (step 4) — non-blocking, cheap, serves F-Droid users. - ⬜ File the DAVx5 issue (step 4) — non-blocking, cheap, serves F-Droid users.
Note it now means "sync into an app that has no provider", so the ask has Note it now means "sync into an app that has no provider", so the ask has
changed shape. changed shape.
@@ -264,9 +274,9 @@ These carry over from [`PLAN.md`](PLAN.md) §9; resolved ones are struck through
resolved: our own store expands a series at read time and writes an edit to resolved: our own store expands a series at read time and writes an edit to
one occurrence as a `RECURRENCE-ID` override sharing the master's UID; in one occurrence as a `RECURRENCE-ID` override sharing the master's UID; in
External mode the edit still goes through the instances URI. External mode the edit still goes through the instances URI.
6. **Resolver ordering / mode-selection UX** `autoMode()` picks a sane default 6. ~~**Resolver ordering / mode-selection UX**~~ resolved: `autoMode()` picks the
today (see [`ARCHITECTURE.md`](ARCHITECTURE.md) §4.1); the Settings override it default (see [`ARCHITECTURE.md`](ARCHITECTURE.md) §4.1) and Settings → Storage
assumes is not built yet. → Task store is the override it always assumed.
7. ~~**Sync protocol coverage**, account model, conflict resolution — the next 7. ~~**Sync protocol coverage**, account model, conflict resolution — the next
design discussion.~~ Taken up in [`SYNC.md`](SYNC.md); the remaining opens design discussion.~~ Taken up in [`SYNC.md`](SYNC.md); the remaining opens
live on that document's list. live on that document's list.

View File

@@ -42,7 +42,7 @@
|---|---|---|---| |---|---|---|---|
| 1 | Merge `fix/provider-interaction-review` | unmerged and rotting; touches the same permission flow as step 2 | ✅ done | | 1 | Merge `fix/provider-interaction-review` | unmerged and rotting; touches the same permission flow as step 2 | ✅ done |
| 2 | Vendor `:provider` under our own authority | the identity, done once — and it ships a complete local-first app | ✅ done | | 2 | Vendor `:provider` under our own authority | the identity, done once — and it ships a complete local-first app | ✅ done |
| 3 | Export / backup | our data now lives only in our app's private storage | ✅ backend done; no UI yet | | 3 | Export / backup | our data now lives only in our app's private storage | ✅ done, UI included |
| 4 | File the DAVx5 issue | cheap, non-blocking, serves F-Droid users | ⬜ | | 4 | File the DAVx5 issue | cheap, non-blocking, serves F-Droid users | ⬜ |
| 5 | Sync adapter | the 1.x arc; designed in [`SYNC.md`](SYNC.md), not yet built | ⬜ | | 5 | Sync adapter | the 1.x arc; designed in [`SYNC.md`](SYNC.md), not yet built | ⬜ |
@@ -202,8 +202,9 @@ than "rank ours first whenever it's non-empty", and needs no database probe:
That permission is dangerous-level, so it can only be there because an earlier That permission is dangerous-level, so it can only be there because an earlier
version asked and the user agreed — which is exactly what "existing Posture A version asked and the user agreed — which is exactly what "existing Posture A
user" means. A fresh install holds nothing and gets local-first. The Settings user" means. A fresh install holds nothing and gets local-first. The Settings
override the rule assumes is not built yet. override the rule assumes is built: Storage → Task store, which asks for the
external provider's permission before committing the switch rather than after.
**Note on the mode vocabulary.** The code has two modes, not three: **Note on the mode vocabulary.** The code has two modes, not three:
`StorageMode.LOCAL` and `StorageMode.EXTERNAL`. As this document says two `StorageMode.LOCAL` and `StorageMode.EXTERNAL`. As this document says two
@@ -211,7 +212,7 @@ paragraphs up, Synced *is* Local with an account attached — so it is derived
state, and giving it its own constant would imply switching sync on is a state, and giving it its own constant would imply switching sync on is a
migration when the whole point is that it isn't. migration when the whole point is that it isn't.
**Export/backup is a v1 feature.** ✅ Backend built (no UI yet). Not, as **Export/backup is a v1 feature.** ✅ Built, screen included. Not, as
previously framed, a migration safety net for "uninstall OpenTasks" — that previously framed, a migration safety net for "uninstall OpenTasks" — that
scenario no longer exists. It's data portability for Local-mode users, whose scenario no longer exists. It's data portability for Local-mode users, whose
tasks otherwise exist in exactly one place with no second copy. On Play, where tasks otherwise exist in exactly one place with no second copy. On Play, where