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:
@@ -7,15 +7,16 @@ import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.agendula.data.di.ApplicationScope
|
||||
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.room.DatabaseCheckpoint
|
||||
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* 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
|
||||
// v0.3.x install's tasks, both before anything reads a store.
|
||||
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()
|
||||
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
|
||||
// alarms against whichever store autoMode happens to pick would arm
|
||||
// them off the wrong one — or off an empty one, mid-import.
|
||||
runCatching {
|
||||
startupGate.awaitReady()
|
||||
started.set(true)
|
||||
scheduler.sync()
|
||||
}
|
||||
}
|
||||
@@ -61,6 +71,10 @@ class AgendulaApp : Application() {
|
||||
interface AppEntryPoint {
|
||||
fun reminderScheduler(): ReminderScheduler
|
||||
fun startupGate(): StartupGate
|
||||
fun providerResolver(): ProviderResolver
|
||||
|
||||
@ApplicationScope
|
||||
fun applicationScope(): CoroutineScope
|
||||
fun databaseCheckpoint(): DatabaseCheckpoint
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,23 @@ import javax.inject.Singleton
|
||||
/** Where an export ended up, for the UI to report. */
|
||||
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
|
||||
@@ -44,22 +59,30 @@ class ExportWriter @Inject constructor(
|
||||
/**
|
||||
* Writes every document into [treeUri], a directory the user picked.
|
||||
*
|
||||
* Overwrites same-named files rather than letting SAF append " (1)" — an
|
||||
* export is a snapshot, and silently accumulating `Groceries-3 (4).ics` makes
|
||||
* the folder useless as a backup.
|
||||
* A same-named file is truncated and rewritten in place rather than deleted
|
||||
* and recreated: SAF would otherwise append " (1)" and turn the folder into
|
||||
* 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 =
|
||||
withContext(io) {
|
||||
val tree = DocumentFile.fromTreeUri(context, treeUri)
|
||||
?: throw ExportFailedException("Cannot open the chosen folder")
|
||||
if (!tree.canWrite()) throw ExportFailedException("The chosen folder is not writable")
|
||||
runCatching {
|
||||
val tree = DocumentFile.fromTreeUri(context, treeUri)
|
||||
?: throw ExportFailedException(ExportFailure.FOLDER_UNAVAILABLE)
|
||||
if (!tree.canWrite()) throw ExportFailedException(ExportFailure.FOLDER_NOT_WRITABLE)
|
||||
val existing = tree.listFiles().associateBy { it.name }
|
||||
|
||||
documents.forEach { document ->
|
||||
tree.findFile(document.fileName)?.delete()
|
||||
val file = tree.createFile(MIME_ICALENDAR, document.fileName)
|
||||
?: throw ExportFailedException("Cannot create ${document.fileName}")
|
||||
write(file.uri, document.content)
|
||||
}
|
||||
documents.forEach { document ->
|
||||
val file = existing[document.fileName]
|
||||
?: tree.createFile(MIME_ICALENDAR, document.fileName)
|
||||
?: throw ExportFailedException(ExportFailure.CANNOT_CREATE_FILE)
|
||||
write(file.uri, document.content)
|
||||
}
|
||||
}.getOrElse { throw asExportFailure(it) }
|
||||
ExportResult(documents.size, documents.map { it.fileName })
|
||||
}
|
||||
|
||||
@@ -80,7 +103,7 @@ class ExportWriter @Inject constructor(
|
||||
zip.closeEntry()
|
||||
}
|
||||
}
|
||||
} ?: throw ExportFailedException("Cannot write to the chosen file")
|
||||
} ?: throw ExportFailedException(ExportFailure.WRITE_FAILED)
|
||||
}.getOrElse { throw asExportFailure(it) }
|
||||
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
|
||||
// previous, longer one behind and produces a corrupt file.
|
||||
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) }
|
||||
}
|
||||
|
||||
@@ -98,8 +121,8 @@ class ExportWriter @Inject constructor(
|
||||
is ExportFailedException -> cause
|
||||
// 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).
|
||||
is SecurityException -> ExportFailedException("Lost access to the chosen location", cause)
|
||||
is IOException -> ExportFailedException(cause.message ?: "Could not write the export", cause)
|
||||
is SecurityException -> ExportFailedException(ExportFailure.LOST_ACCESS, cause)
|
||||
is IOException -> ExportFailedException(ExportFailure.WRITE_FAILED, cause)
|
||||
else -> cause
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -20,8 +22,8 @@ import javax.inject.Singleton
|
||||
* The self-scheduled due-reminder engine. Nothing else delivers task reminders —
|
||||
* 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
|
||||
* on app start, on boot, and on an external provider change; it diffs against
|
||||
* [ScheduledReminderStore] so only changed alarms move.
|
||||
* on app start, on boot, on a store switch, and on an external provider change;
|
||||
* it diffs against [ScheduledReminderStore] so only changed alarms move.
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderScheduler @Inject constructor(
|
||||
@@ -32,19 +34,32 @@ class ReminderScheduler @Inject constructor(
|
||||
private val providerResolver: ProviderResolver,
|
||||
@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()
|
||||
// Gate on whether the store is readable, not on whether a provider
|
||||
// resolves: our own store deliberately resolves to no provider, so the
|
||||
// latter clears every reminder in the default mode.
|
||||
if (!settings.remindersEnabled || !providerResolver.canReadStore()) {
|
||||
clearAll()
|
||||
return@withContext
|
||||
return
|
||||
}
|
||||
val now = System.currentTimeMillis()
|
||||
val horizon = now + WINDOW_MS
|
||||
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
|
||||
.getOrElse { return@withContext }
|
||||
.getOrElse { return }
|
||||
|
||||
// One reminder per *occurrence*: a recurring series yields a row per
|
||||
// occurrence, all sharing a taskId, so this is a Set rather than a
|
||||
|
||||
@@ -23,6 +23,9 @@ interface ProviderEnvironment {
|
||||
|
||||
/** Whether this app currently holds [permission]. */
|
||||
fun isGranted(permission: String): Boolean
|
||||
|
||||
/** [packageName]'s own app name, or null when it cannot be read. */
|
||||
fun appLabel(packageName: String): String?
|
||||
}
|
||||
|
||||
@Singleton
|
||||
@@ -35,4 +38,9 @@ class AndroidProviderEnvironment @Inject constructor(
|
||||
|
||||
override fun isGranted(permission: String): Boolean =
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -10,19 +10,17 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
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 de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.ui.common.OnResume
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
|
||||
import de.jeanlucmakiola.agendula.ui.navigation.AgendulaNavHost
|
||||
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
|
||||
// have granted the permission (or installed a provider) in system Settings and
|
||||
// come back, and otherwise the gate would hold until the process restarts.
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) permissionViewModel.refresh()
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
OnResume { permissionViewModel.refresh() }
|
||||
|
||||
// Neither gate can show in OWN mode (that store is always READY), so the way
|
||||
// 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.
|
||||
val fallback = stringResource(R.string.onboarding_use_own_store)
|
||||
|
||||
when (permission.status) {
|
||||
ProviderStatus.NO_PROVIDER -> Gate(
|
||||
modifier = modifier,
|
||||
title = stringResource(R.string.onboarding_no_provider_title),
|
||||
body = stringResource(R.string.onboarding_no_provider_body),
|
||||
secondaryAction = fallback,
|
||||
onSecondaryAction = permissionViewModel::useOwnStore,
|
||||
)
|
||||
ProviderStatus.NEEDS_PERMISSION -> Gate(
|
||||
modifier = modifier,
|
||||
@@ -67,6 +65,8 @@ fun RootScreen(
|
||||
body = stringResource(R.string.onboarding_permission_body),
|
||||
action = stringResource(R.string.onboarding_permission_button),
|
||||
onAction = { launcher.launch(permission.permissionsToRequest.toTypedArray()) },
|
||||
secondaryAction = fallback,
|
||||
onSecondaryAction = permissionViewModel::useOwnStore,
|
||||
)
|
||||
ProviderStatus.READY -> ReadyGate(modifier = modifier)
|
||||
}
|
||||
@@ -103,6 +103,8 @@ private fun Gate(
|
||||
modifier: Modifier = Modifier,
|
||||
action: String? = null,
|
||||
onAction: () -> Unit = {},
|
||||
secondaryAction: String? = null,
|
||||
onSecondaryAction: () -> Unit = {},
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize().padding(24.dp),
|
||||
@@ -112,5 +114,6 @@ private fun Gate(
|
||||
Text(title, style = MaterialTheme.typography.headlineSmall)
|
||||
Text(body, style = MaterialTheme.typography.bodyMedium)
|
||||
if (action != null) Button(onClick = onAction) { Text(action) }
|
||||
if (secondaryAction != null) TextButton(onClick = onSecondaryAction) { Text(secondaryAction) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,25 @@
|
||||
package de.jeanlucmakiola.agendula.ui.permission
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.ProviderStatus
|
||||
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
|
||||
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.SharingStarted
|
||||
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
|
||||
|
||||
data class PermissionUiState(
|
||||
@@ -21,25 +33,48 @@ data class PermissionUiState(
|
||||
* The Composable owns the actual permission-launcher and store intents; this VM
|
||||
* 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
|
||||
* provider ships in the APK and is reached same-uid, so there is nothing to
|
||||
* install and nothing to grant. It exists for External mode.
|
||||
* In the default Own mode this gate never appears at all — the store is our own
|
||||
* Room database, so there is nothing to install and nothing to grant. It exists
|
||||
* for External mode, which also makes it the only screen an External user can
|
||||
* reach once their provider app stops answering: hence [useOwnStore].
|
||||
*/
|
||||
@HiltViewModel
|
||||
class PermissionViewModel @Inject constructor(
|
||||
private val repository: TasksRepository,
|
||||
private val providerResolver: ProviderResolver,
|
||||
private val prefs: SettingsPrefs,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(PermissionUiState())
|
||||
val state: StateFlow<PermissionUiState> = _state.asStateFlow()
|
||||
private val refreshes = MutableStateFlow(0)
|
||||
|
||||
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). */
|
||||
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()
|
||||
_state.value = PermissionUiState(
|
||||
return PermissionUiState(
|
||||
status = repository.providerStatus(),
|
||||
// Null in OWN mode, where there is no provider and nothing to grant.
|
||||
permissionsToRequest = provider
|
||||
|
||||
@@ -49,6 +49,7 @@ import androidx.compose.material.icons.rounded.AccountTree
|
||||
import androidx.compose.material.icons.rounded.Circle
|
||||
import androidx.compose.material.icons.rounded.Flag
|
||||
import androidx.compose.material.icons.rounded.Percent
|
||||
import androidx.compose.material.icons.rounded.Storage
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -56,7 +57,6 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -75,13 +75,11 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
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 de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||
import de.jeanlucmakiola.agendula.ui.export.ExportScreen
|
||||
import de.jeanlucmakiola.floret.components.AboutCard
|
||||
import de.jeanlucmakiola.floret.components.AboutLink
|
||||
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.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||
import de.jeanlucmakiola.agendula.ui.common.OnResume
|
||||
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
|
||||
|
||||
/** 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),
|
||||
@@ -125,7 +135,7 @@ fun SettingsScreen(
|
||||
|
||||
// Inside a sub-screen, system back (button or gesture) returns to the hub
|
||||
// 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(
|
||||
modifier = modifier
|
||||
@@ -143,6 +153,19 @@ fun SettingsScreen(
|
||||
SlideInSection(visible = section == SettingsSection.Reminders) {
|
||||
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) },
|
||||
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)
|
||||
ReportProblemRow(position = Position.Bottom)
|
||||
|
||||
@@ -670,15 +700,10 @@ private fun rememberExactAlarmAllowed(context: Context): Boolean {
|
||||
context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms(),
|
||||
)
|
||||
}
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
DisposableEffect(lifecycle) {
|
||||
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()
|
||||
}
|
||||
OnResume {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
allowed = context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms()
|
||||
}
|
||||
lifecycle.addObserver(obs)
|
||||
onDispose { lifecycle.removeObserver(obs) }
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
@@ -3,18 +3,27 @@ package de.jeanlucmakiola.agendula.ui.settings
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.SettingsPrefs
|
||||
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.recoveringFromProviderFailure
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -23,6 +32,22 @@ data class SettingsUiState(
|
||||
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
|
||||
* same instance), so a theme change applies app-wide at once.
|
||||
@@ -30,6 +55,9 @@ data class SettingsUiState(
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val prefs: SettingsPrefs,
|
||||
private val resolver: ProviderResolver,
|
||||
private val environment: ProviderEnvironment,
|
||||
@IoDispatcher io: CoroutineDispatcher,
|
||||
repository: TasksRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -44,6 +72,31 @@ class SettingsViewModel @Inject constructor(
|
||||
SettingsUiState(settings, lists)
|
||||
}.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 setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) }
|
||||
fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) }
|
||||
|
||||
@@ -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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -246,6 +246,37 @@
|
||||
<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="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) -->
|
||||
<plurals name="reminder_minutes">
|
||||
<item quantity="one">%1$d minute before</item>
|
||||
|
||||
@@ -23,6 +23,7 @@ class ProviderResolverTest {
|
||||
) : ProviderEnvironment {
|
||||
override fun packageDeclaring(authority: String): String? = installed[authority]
|
||||
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" }
|
||||
|
||||
Reference in New Issue
Block a user