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.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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
val tree = DocumentFile.fromTreeUri(context, treeUri)
|
runCatching {
|
||||||
?: throw ExportFailedException("Cannot open the chosen folder")
|
val tree = DocumentFile.fromTreeUri(context, treeUri)
|
||||||
if (!tree.canWrite()) throw ExportFailedException("The chosen folder is not writable")
|
?: throw ExportFailedException(ExportFailure.FOLDER_UNAVAILABLE)
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
|
|||||||
@@ -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,15 +700,10 @@ 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 ->
|
allowed = context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms()
|
||||||
if (event == Lifecycle.Event.ON_RESUME && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
allowed = context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
lifecycle.addObserver(obs)
|
|
||||||
onDispose { lifecycle.removeObserver(obs) }
|
|
||||||
}
|
}
|
||||||
return allowed
|
return allowed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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">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>
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user