Merge remote-tracking branch 'origin/release/v1.0.0' into feat/caldav-sync
# Conflicts: # app/build.gradle.kts # app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt # app/src/main/java/de/jeanlucmakiola/agendula/data/di/Qualifiers.kt # app/src/main/java/de/jeanlucmakiola/agendula/ui/export/ExportScreen.kt # app/src/main/java/de/jeanlucmakiola/agendula/ui/settings/SettingsScreen.kt # docs/PRIVACY.md # gradle/libs.versions.toml
This commit is contained in:
+13
-16
@@ -27,10 +27,11 @@ android {
|
||||
// a bumped versionName into main triggers .gitea/workflows/release.yaml,
|
||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||
// PATCH from versionName, e.g. 0.2.0 -> 200). The Gitea release is marked
|
||||
// as a pre-release while MAJOR is 0. See docs/RELEASING.md.
|
||||
versionCode = 400
|
||||
versionName = "0.4.0"
|
||||
// PATCH from versionName, e.g. 1.0.0 -> 10000). Releases were flagged as
|
||||
// pre-releases while MAJOR was 0; 1.0.0 is the first stable one, and the
|
||||
// pipeline graduates it on its own. See docs/RELEASING.md.
|
||||
versionCode = 10000
|
||||
versionName = "1.0.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
@@ -190,6 +191,7 @@ dependencies {
|
||||
|
||||
implementation(libs.hilt.android)
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
implementation(libs.androidx.hilt.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
ksp(libs.hilt.compiler)
|
||||
|
||||
@@ -202,9 +204,7 @@ dependencies {
|
||||
implementation(libs.androidx.hilt.work)
|
||||
ksp(libs.androidx.hilt.compiler)
|
||||
|
||||
// RFC 5545 recurrence expansion, in-process. Pinned at 0.12.2 — 0.16.0
|
||||
// removed RecurrenceSet. rfc5545-datetime comes with it and is part of its
|
||||
// API surface, so it isn't declared separately.
|
||||
// RFC 5545 recurrence expansion, in-process; see the catalog for the pin.
|
||||
implementation(libs.dmfs.lib.recur)
|
||||
|
||||
// Vendored dav4jvm — the CalDAV protocol layer. See dav/PROVENANCE.md.
|
||||
@@ -225,17 +225,14 @@ dependencies {
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.androidx.documentfile)
|
||||
|
||||
implementation(libs.androidx.glance.appwidget)
|
||||
implementation(libs.androidx.glance.material3)
|
||||
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation("de.jeanlucmakiola.floret:core-time")
|
||||
implementation("de.jeanlucmakiola.floret:core-reminders")
|
||||
implementation("de.jeanlucmakiola.floret:core-locale")
|
||||
implementation("de.jeanlucmakiola.floret:core-crash")
|
||||
implementation("de.jeanlucmakiola.floret:identity")
|
||||
implementation("de.jeanlucmakiola.floret:components")
|
||||
implementation(libs.floret.core.time)
|
||||
implementation(libs.floret.core.reminders)
|
||||
implementation(libs.floret.core.locale)
|
||||
implementation(libs.floret.core.crash)
|
||||
implementation(libs.floret.identity)
|
||||
implementation(libs.floret.components)
|
||||
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
debugImplementation(libs.androidx.ui.test.manifest)
|
||||
|
||||
Vendored
+4
-10
@@ -5,17 +5,11 @@
|
||||
# Room instantiates its generated <Database>_Impl reflectively through a no-arg
|
||||
# constructor. R8 under AGP 9 keeps the class but prunes that constructor, since
|
||||
# nothing calls it directly — Room then throws InstantiationException, reported
|
||||
# as "Failed to create an instance of ...". We pull Room in transitively via
|
||||
# Glance -> WorkManager, whose WorkDatabase is built by WorkManagerInitializer
|
||||
# at startup, so the app died on launch in every minified build (issue #1).
|
||||
# as "Failed to create an instance of ...". This first bit us through a
|
||||
# transitive Room (Glance -> WorkManager -> WorkDatabase, built at startup:
|
||||
# issue #1); Glance is gone and Room is now our own task store, so the rule
|
||||
# matters more, not less — TasksDatabase is built on the first store read.
|
||||
-keep class * extends androidx.room.RoomDatabase { <init>(); }
|
||||
|
||||
# WorkManager likewise looks its workers up by name and calls this constructor
|
||||
# reflectively — same pruning, but it only bites once a worker actually runs
|
||||
# (Glance's widget updates), so keep it explicitly rather than wait for it.
|
||||
-keep class * extends androidx.work.ListenableWorker {
|
||||
<init>(android.content.Context, androidx.work.WorkerParameters);
|
||||
}
|
||||
|
||||
# Compose Compiler may keep its own; defaults are fine
|
||||
-dontwarn org.jetbrains.annotations.**
|
||||
|
||||
+6
-2
@@ -5,6 +5,7 @@ import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import org.junit.After
|
||||
@@ -319,12 +320,15 @@ class RoomTasksDataSourceTest {
|
||||
fun alarmsRoundTripAndReplaceRatherThanAccumulate() {
|
||||
val id = source.insertTask(form(due = now + 1.days))
|
||||
|
||||
// The whole reminder, not just the minute count: collapsing it to a bare
|
||||
// Int is what fired an imported START-referenced alarm off DUE, and an
|
||||
// alarm this seam sets from the UI is always due-referenced.
|
||||
source.setAlarm(id, 30)
|
||||
assertThat(source.alarms()[id]).isEqualTo(30)
|
||||
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(minutesBefore = 30))
|
||||
|
||||
source.setAlarm(id, 60)
|
||||
assertThat(db.alarms().forTask(id)).hasSize(1)
|
||||
assertThat(source.alarms()[id]).isEqualTo(60)
|
||||
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(minutesBefore = 60))
|
||||
|
||||
source.setAlarm(id, null)
|
||||
assertThat(source.alarms()).doesNotContainKey(id)
|
||||
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.transfer
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||
import de.jeanlucmakiola.agendula.domain.Priority
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
import javax.inject.Provider
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The copy out of an external provider and into Room — the upgrade path every
|
||||
* released install actually needs, since no release ever bundled the provider
|
||||
* `OneShotImport` reads (see `docs/OWN-STORE.md`).
|
||||
*
|
||||
* The source is a fake [TasksDataSource] rather than a live OpenTasks: what is
|
||||
* worth testing is the write half — id remapping, uid collisions, verified
|
||||
* counts, the once-only guard — and pinning that to a device with a third-party
|
||||
* app installed would mean it never ran. Instrumented all the same, because the
|
||||
* destination is a real Room database in a real transaction.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExternalImportTest {
|
||||
|
||||
@get:Rule
|
||||
val temp = TemporaryFolder()
|
||||
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
private lateinit var scope: CoroutineScope
|
||||
private lateinit var prefs: DataStore<Preferences>
|
||||
private lateinit var db: TasksDatabase
|
||||
private lateinit var source: FakeExternalStore
|
||||
private lateinit var importer: ExternalImport
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
prefs = PreferenceDataStoreFactory.create(scope = scope) {
|
||||
temp.newFile("transfer-${counter++}.preferences_pb").also(File::delete)
|
||||
}
|
||||
db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
source = FakeExternalStore()
|
||||
importer = ExternalImport(
|
||||
external = Provider { source },
|
||||
resolver = ProviderResolver(NoProviderInstalled),
|
||||
database = db,
|
||||
dataStore = prefs,
|
||||
io = Dispatchers.IO,
|
||||
)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
db.close()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun copiesListsTasksAndAlarms() = runBlocking {
|
||||
source.lists = listOf(list(7, "Errands"), list(9, "Work"))
|
||||
source.tasks = mapOf(
|
||||
7L to listOf(task(100, "Milk"), task(101, "Bread")),
|
||||
9L to listOf(task(200, "Invoice")),
|
||||
)
|
||||
source.alarms = mapOf(100L to TaskReminder(minutesBefore = 30))
|
||||
|
||||
val result = importer.run()
|
||||
|
||||
assertThat(result).isEqualTo(
|
||||
TransferResult.Copied(TransferCounts(lists = 2, tasks = 3, alarms = 1)),
|
||||
)
|
||||
assertThat(db.taskLists().lists().map { it.list.name })
|
||||
.containsExactly("Errands", "Work")
|
||||
assertThat(db.tasks().tasks(listId = null, includeCompleted = true).map { it.task.title })
|
||||
.containsExactly("Milk", "Bread", "Invoice")
|
||||
assertThat(importer.hasRun.first()).isTrue()
|
||||
}
|
||||
|
||||
/** Every list arrives device-only: the account belongs to the sync app. */
|
||||
@Test
|
||||
fun importedListsAreDeviceOnly() = runBlocking {
|
||||
source.lists = listOf(list(7, "Shared", accountName = "me@example.org"))
|
||||
source.tasks = mapOf(7L to listOf(task(100, "Milk")))
|
||||
|
||||
importer.run()
|
||||
|
||||
assertThat(db.taskLists().lists().single().list.accountId).isNull()
|
||||
}
|
||||
|
||||
/** Provider row ids are the source's; Room mints its own and the link follows. */
|
||||
@Test
|
||||
fun remapsParentIdsOntoTheNewRowIds() = runBlocking {
|
||||
source.lists = listOf(list(7, "Errands"))
|
||||
// Child before parent, so a naive single pass would not find the parent.
|
||||
source.tasks = mapOf(
|
||||
7L to listOf(task(100, "Subtask", parentId = 200), task(200, "Parent")),
|
||||
)
|
||||
|
||||
importer.run()
|
||||
|
||||
val rows = db.tasks().tasks(listId = null, includeCompleted = true).map { it.task }
|
||||
val parent = rows.single { it.title == "Parent" }
|
||||
val child = rows.single { it.title == "Subtask" }
|
||||
assertThat(child.parentId).isEqualTo(parent.id)
|
||||
assertThat(child.parentId).isNotEqualTo(200L)
|
||||
}
|
||||
|
||||
/**
|
||||
* A `RECURRENCE-ID` override reaches the read seam as another master-shaped row
|
||||
* sharing its series' uid. The unique index on (list, uid, recurrence_id)
|
||||
* would reject it and take the whole copy down, so it gets a fresh uid.
|
||||
*/
|
||||
@Test
|
||||
fun aDuplicateUidDoesNotAbortTheCopy() = runBlocking {
|
||||
source.lists = listOf(list(7, "Errands"))
|
||||
source.tasks = mapOf(
|
||||
7L to listOf(
|
||||
task(100, "Weekly", uid = "shared-uid"),
|
||||
task(101, "Weekly, that one week", uid = "shared-uid"),
|
||||
),
|
||||
)
|
||||
|
||||
val result = importer.run()
|
||||
|
||||
assertThat(result).isInstanceOf(TransferResult.Copied::class.java)
|
||||
val uids = db.tasks().tasks(listId = null, includeCompleted = true).map { it.task.uid }
|
||||
assertThat(uids).hasSize(2)
|
||||
assertThat(uids.toSet()).hasSize(2)
|
||||
assertThat(uids).contains("shared-uid")
|
||||
}
|
||||
|
||||
/** A START-referenced reminder must not come across as a before-due one. */
|
||||
@Test
|
||||
fun preservesTheAlarmReference() = runBlocking {
|
||||
source.lists = listOf(list(7, "Errands"))
|
||||
source.tasks = mapOf(7L to listOf(task(100, "Standup")))
|
||||
source.alarms = mapOf(100L to TaskReminder(minutesBefore = 10, fromStart = true))
|
||||
|
||||
importer.run()
|
||||
|
||||
val alarm = db.alarms().all().single()
|
||||
assertThat(alarm.reference).isEqualTo(AlarmReference.START)
|
||||
assertThat(alarm.minutesBefore).isEqualTo(10)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anEmptySourceWritesNothingAndIsNotMarkedDone() = runBlocking {
|
||||
val result = importer.run()
|
||||
|
||||
assertThat(result).isEqualTo(TransferResult.NothingToCopy)
|
||||
assertThat(db.taskLists().lists()).isEmpty()
|
||||
// Still on offer: there was nothing to copy, not a copy that happened.
|
||||
assertThat(importer.hasRun.first()).isFalse()
|
||||
}
|
||||
|
||||
/** A read that blows up must leave Room exactly as it was. */
|
||||
@Test
|
||||
fun aFailedReadRollsBackAndLeavesTheGuardOpen() = runBlocking {
|
||||
source.lists = listOf(list(7, "Errands"))
|
||||
source.failOnExport = true
|
||||
|
||||
val result = importer.run()
|
||||
|
||||
assertThat(result).isInstanceOf(TransferResult.Failed::class.java)
|
||||
assertThat(db.taskLists().lists()).isEmpty()
|
||||
assertThat(importer.hasRun.first()).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun previewCountsWhatARunWouldWrite() = runBlocking {
|
||||
source.lists = listOf(list(7, "Errands"), list(9, "Work"))
|
||||
source.tasks = mapOf(
|
||||
7L to listOf(task(100, "Milk"), task(101, "Bread")),
|
||||
9L to listOf(task(200, "Invoice")),
|
||||
)
|
||||
source.alarms = mapOf(100L to TaskReminder(minutesBefore = 30))
|
||||
// preview() resolves the provider itself, so it needs one to be installed.
|
||||
val withProvider = ExternalImport(
|
||||
external = Provider { source },
|
||||
resolver = ProviderResolver(OpenTasksInstalledAndGranted),
|
||||
database = db,
|
||||
dataStore = prefs,
|
||||
io = Dispatchers.IO,
|
||||
)
|
||||
|
||||
assertThat(withProvider.preview())
|
||||
.isEqualTo(TransferCounts(lists = 2, tasks = 3, alarms = 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun previewIsNullWithoutAReadableProvider() = runBlocking {
|
||||
assertThat(importer.preview()).isNull()
|
||||
}
|
||||
|
||||
// --- fixtures --------------------------------------------------------------
|
||||
|
||||
private fun list(id: Long, name: String, accountName: String = "Device") = TaskList(
|
||||
id = id,
|
||||
name = name,
|
||||
color = 0xFF7E57C2.toInt(),
|
||||
accountName = accountName,
|
||||
accountType = "org.dmfs.account.LOCAL",
|
||||
isSynced = true,
|
||||
isVisible = true,
|
||||
owner = null,
|
||||
)
|
||||
|
||||
private fun task(
|
||||
id: Long,
|
||||
title: String,
|
||||
uid: String? = "uid-$id",
|
||||
parentId: Long? = null,
|
||||
) = ExportTask(
|
||||
taskId = id,
|
||||
uid = uid,
|
||||
title = title,
|
||||
description = null,
|
||||
location = null,
|
||||
url = null,
|
||||
priority = Priority.NONE,
|
||||
status = TaskStatus.NEEDS_ACTION,
|
||||
percentComplete = null,
|
||||
start = null,
|
||||
due = Instant.fromEpochMilliseconds(1_800_000_000_000),
|
||||
isAllDay = false,
|
||||
completedAt = null,
|
||||
created = null,
|
||||
lastModified = null,
|
||||
rrule = null,
|
||||
rdate = null,
|
||||
parentId = parentId,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
var counter = 0
|
||||
}
|
||||
}
|
||||
|
||||
/** Only the three reads the copy makes; everything else is out of scope. */
|
||||
private class FakeExternalStore : TasksDataSource {
|
||||
var lists: List<TaskList> = emptyList()
|
||||
var tasks: Map<Long, List<ExportTask>> = emptyMap()
|
||||
var alarms: Map<Long, TaskReminder> = emptyMap()
|
||||
var failOnExport = false
|
||||
|
||||
override fun taskLists(): List<TaskList> = lists
|
||||
|
||||
override fun exportTasks(listId: Long): List<ExportTask> {
|
||||
if (failOnExport) error("provider went away mid-read")
|
||||
return tasks[listId].orEmpty()
|
||||
}
|
||||
|
||||
override fun alarms(): Map<Long, TaskReminder> = alarms
|
||||
|
||||
override fun tasks(query: TaskQuery): List<Task> = unused()
|
||||
override fun task(taskId: Long): Task? = unused()
|
||||
override fun subtasks(parentTaskId: Long): List<Task> = unused()
|
||||
override fun insertTask(form: TaskForm): Long = unused()
|
||||
override fun updateTask(taskId: Long, form: TaskForm) = unused()
|
||||
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) = unused()
|
||||
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = unused()
|
||||
override fun setCompleted(taskId: Long, completed: Boolean) = unused()
|
||||
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) = unused()
|
||||
override fun deleteTask(taskId: Long) = unused()
|
||||
override fun createLocalList(name: String, color: Int): Long = unused()
|
||||
override fun updateList(listId: Long, name: String, color: Int) = unused()
|
||||
override fun deleteList(listId: Long) = unused()
|
||||
override fun registerObserver(onChange: () -> Unit): AutoCloseable = unused()
|
||||
|
||||
private fun unused(): Nothing = error("the copy does not call this")
|
||||
}
|
||||
|
||||
/** No tasks provider on the device: `preview()` has nothing to read. */
|
||||
private object NoProviderInstalled : ProviderEnvironment {
|
||||
override fun packageDeclaring(authority: String): String? = null
|
||||
override fun isGranted(permission: String): Boolean = false
|
||||
override fun appLabel(packageName: String): String? = null
|
||||
}
|
||||
|
||||
private object OpenTasksInstalledAndGranted : ProviderEnvironment {
|
||||
override fun packageDeclaring(authority: String): String? =
|
||||
"org.dmfs.tasks".takeIf { authority == "org.dmfs.tasks" }
|
||||
|
||||
override fun isGranted(permission: String): Boolean = permission.startsWith("org.dmfs.permission.")
|
||||
override fun appLabel(packageName: String): String = "OpenTasks"
|
||||
}
|
||||
@@ -113,16 +113,24 @@
|
||||
<!-- Re-sync reminders when an external provider changes — DAVx5 pulling
|
||||
tasks while Agendula is backgrounded. External mode only: in OWN mode
|
||||
nothing outside the app can change our data, and Room's
|
||||
InvalidationTracker covers our own writes. An intent-filter host must
|
||||
be a literal, so both external authorities are listed. -->
|
||||
InvalidationTracker covers our own writes.
|
||||
|
||||
An intent-filter host must be a literal, so both external authorities
|
||||
are listed — one filter each. Two <data> tags in a single filter would
|
||||
mean the same thing (Android takes the cross product of every data
|
||||
attribute in a filter), but reads as if it might not, which is what
|
||||
lint's IntentFilterUniqueDataAttributes warns about. -->
|
||||
<receiver
|
||||
android:name=".data.reminders.ProviderChangeReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.PROVIDER_CHANGED" />
|
||||
<data android:scheme="content" android:host="org.tasks.opentasks" />
|
||||
<data android:scheme="content" android:host="org.dmfs.tasks" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.PROVIDER_CHANGED" />
|
||||
<data android:scheme="content" android:host="org.tasks.opentasks" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Sync plumbing. The stub provider exists only to give the sync
|
||||
|
||||
@@ -12,7 +12,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
@@ -30,6 +30,7 @@ import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
|
||||
/**
|
||||
* Single activity. The theme follows [SettingsViewModel]; [RootScreen] is the
|
||||
@@ -39,7 +40,10 @@ import javax.inject.Inject
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
@Inject lateinit var demoSeeder: DemoSeeder
|
||||
// A Provider, not the seeder itself: the call site below is dead in a release
|
||||
// build (`BuildConfig.DEBUG` is a compile-time false), and injecting the
|
||||
// instance would still construct one on every launch of the shipped app.
|
||||
@Inject lateinit var demoSeeder: Provider<DemoSeeder>
|
||||
|
||||
@Inject lateinit var accounts: AccountRepository
|
||||
|
||||
@@ -93,7 +97,7 @@ class MainActivity : ComponentActivity() {
|
||||
// Debug-only sample data: `am start ... --ez agendula_seed true`. Seeds a
|
||||
// local (non-syncing) demo list once; no-op without the extra.
|
||||
if (BuildConfig.DEBUG && intent.getBooleanExtra(EXTRA_SEED, false)) {
|
||||
lifecycleScope.launch { runCatching { demoSeeder.seed() } }
|
||||
lifecycleScope.launch { runCatching { demoSeeder.get().seed() } }
|
||||
}
|
||||
setContent {
|
||||
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
||||
|
||||
@@ -103,6 +103,14 @@ abstract class DataBindModule {
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindLoginFlowRecord(impl: PendingLoginFlowStore): LoginFlowRecord
|
||||
|
||||
// Deliberately unqualified-free of the routing above: this is the external
|
||||
// store itself, for the one caller that has to read it while another store is
|
||||
// the active one.
|
||||
@Binds
|
||||
@Singleton
|
||||
@ExternalStore
|
||||
abstract fun bindExternalTasksDataSource(impl: AndroidTasksDataSource): TasksDataSource
|
||||
}
|
||||
|
||||
@Module
|
||||
|
||||
@@ -48,3 +48,17 @@ annotation class CredentialsDataStore
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class SyncStateDataStore
|
||||
|
||||
/**
|
||||
* Marks the **external** provider's [de.jeanlucmakiola.agendula.data.tasks
|
||||
* .TasksDataSource] — the OpenTasks/tasks.org path specifically, rather than
|
||||
* whichever store the active mode selects.
|
||||
*
|
||||
* Only the one-time copy into our own store needs to name a store this way;
|
||||
* everything else goes through the routed source and must keep doing so. Having
|
||||
* it as a binding rather than depending on the concrete class is also what lets
|
||||
* that copy be tested against a fake.
|
||||
*/
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class ExternalStore
|
||||
|
||||
@@ -18,8 +18,9 @@ import javax.inject.Singleton
|
||||
data class ExportResult(val fileCount: Int, val taskListNames: List<String>)
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Why an export failed, as a value rather than a message: the UI is translated
|
||||
* (see `res/xml/locales_config.xml`), so the wording has to come from a string
|
||||
* resource rather than being built here.
|
||||
*/
|
||||
enum class ExportFailure {
|
||||
FOLDER_UNAVAILABLE,
|
||||
|
||||
@@ -79,7 +79,6 @@ class TaskNotifier @Inject constructor(
|
||||
}
|
||||
|
||||
private fun ensureChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = context.getSystemService(NotificationManager::class.java)
|
||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
||||
manager.createNotificationChannel(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
|
||||
/**
|
||||
* The subset of the OpenTasks `TaskContract` that Agendula uses, vendored as
|
||||
@@ -150,7 +151,7 @@ object TasksContract {
|
||||
const val TYPE_MESSAGE = 1
|
||||
}
|
||||
|
||||
fun propertiesUri(authority: String): Uri = Uri.parse("content://$authority/${Properties.PATH}")
|
||||
fun propertiesUri(authority: String): Uri = "content://$authority/${Properties.PATH}".toUri()
|
||||
|
||||
// --- status values (TaskColumns.STATUS_*) --------------------------------
|
||||
const val STATUS_NEEDS_ACTION = 0
|
||||
@@ -161,12 +162,12 @@ object TasksContract {
|
||||
/** Priority 0 means "no priority"; 1 is highest, 9 lowest (iCalendar). */
|
||||
const val PRIORITY_NONE = 0
|
||||
|
||||
fun authorityUri(authority: String): Uri = Uri.parse("content://$authority")
|
||||
fun listsUri(authority: String): Uri = Uri.parse("content://$authority/${Lists.PATH}")
|
||||
fun authorityUri(authority: String): Uri = "content://$authority".toUri()
|
||||
fun listsUri(authority: String): Uri = "content://$authority/${Lists.PATH}".toUri()
|
||||
fun listUri(authority: String, listId: Long): Uri =
|
||||
Uri.parse("content://$authority/${Lists.PATH}/$listId")
|
||||
fun tasksUri(authority: String): Uri = Uri.parse("content://$authority/${Tasks.PATH}")
|
||||
fun instancesUri(authority: String): Uri = Uri.parse("content://$authority/${Instances.PATH}")
|
||||
"content://$authority/${Lists.PATH}/$listId".toUri()
|
||||
fun tasksUri(authority: String): Uri = "content://$authority/${Tasks.PATH}".toUri()
|
||||
fun instancesUri(authority: String): Uri = "content://$authority/${Instances.PATH}".toUri()
|
||||
|
||||
/**
|
||||
* A single occurrence. Updating through this URI is how a *recurring* task is
|
||||
@@ -175,7 +176,7 @@ object TasksContract {
|
||||
* the series anchor, which is what writing to `tasks/<id>` would do.
|
||||
*/
|
||||
fun instanceUri(authority: String, instanceId: Long): Uri =
|
||||
Uri.parse("content://$authority/${Instances.PATH}/$instanceId")
|
||||
"content://$authority/${Instances.PATH}/$instanceId".toUri()
|
||||
|
||||
/** Append the sync-adapter params required to write local-account rows. */
|
||||
fun asSyncAdapter(uri: Uri, accountName: String, accountType: String): Uri =
|
||||
|
||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.agendula.data.tasks.legacy
|
||||
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.util.Log
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
@@ -68,6 +69,17 @@ class OneShotImport @Inject constructor(
|
||||
/** Whether the import has run. Set before the rename, so both guards hold. */
|
||||
val isDone: Flow<Boolean> = dataStore.data.map { it[IMPORT_DONE] ?: false }
|
||||
|
||||
/**
|
||||
* Whether the last attempt failed and the archived source is still sitting
|
||||
* there unread.
|
||||
*
|
||||
* Recorded because the alternative is what this class used to do: return an
|
||||
* [ImportResult.Failed] that every caller dropped on the floor, leaving an
|
||||
* upgrading user with an empty app, no message, and their tasks in a file
|
||||
* only a developer could find. Settings → Storage offers the retry.
|
||||
*/
|
||||
val lastAttemptFailed: Flow<Boolean> = dataStore.data.map { it[IMPORT_FAILED] ?: false }
|
||||
|
||||
/**
|
||||
* Steps 1–8 of the plan: archive `databases/tasks.db`, import it, record
|
||||
* completion. Safe to call on every launch.
|
||||
@@ -94,7 +106,7 @@ class OneShotImport @Inject constructor(
|
||||
return@withContext ImportResult.NothingToImport
|
||||
}
|
||||
val counts = runCatching { importFrom(source, replaceExisting = true) }
|
||||
.getOrElse { return@withContext ImportResult.Failed(it) }
|
||||
.getOrElse { return@withContext recordFailure(it) }
|
||||
markDone()
|
||||
ImportResult.Imported(counts)
|
||||
}
|
||||
@@ -107,7 +119,7 @@ class OneShotImport @Inject constructor(
|
||||
suspend fun reimportFromArchive(): ImportResult = withContext(Dispatchers.IO) {
|
||||
val source = archivedSource() ?: return@withContext ImportResult.NothingToImport
|
||||
val counts = runCatching { importFrom(source, replaceExisting = true) }
|
||||
.getOrElse { return@withContext ImportResult.Failed(it) }
|
||||
.getOrElse { return@withContext recordFailure(it) }
|
||||
markDone()
|
||||
ImportResult.Imported(counts)
|
||||
}
|
||||
@@ -341,8 +353,22 @@ class OneShotImport @Inject constructor(
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaves a breadcrumb the UI can act on, and one in logcat for a bug report.
|
||||
* The completion flag is deliberately *not* set: the next launch retries on
|
||||
* its own, and the archived source is still where it was.
|
||||
*/
|
||||
private suspend fun recordFailure(cause: Throwable): ImportResult.Failed {
|
||||
Log.e(TAG, "Importing the legacy task database failed; source left in place", cause)
|
||||
dataStore.edit { it[IMPORT_FAILED] = true }
|
||||
return ImportResult.Failed(cause)
|
||||
}
|
||||
|
||||
private suspend fun markDone() {
|
||||
dataStore.edit { it[IMPORT_DONE] = true }
|
||||
dataStore.edit {
|
||||
it[IMPORT_DONE] = true
|
||||
it.remove(IMPORT_FAILED)
|
||||
}
|
||||
}
|
||||
|
||||
private fun CursorColumnReader.instant(name: String): Instant? =
|
||||
@@ -356,6 +382,8 @@ class OneShotImport @Inject constructor(
|
||||
private const val REFERENCE_START = "2"
|
||||
private val SIDECARS = listOf("-journal", "-wal", "-shm")
|
||||
private val IMPORT_DONE = booleanPreferencesKey("legacy_import_done")
|
||||
private val IMPORT_FAILED = booleanPreferencesKey("legacy_import_failed")
|
||||
private const val TAG = "OneShotImport"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.transfer
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import de.jeanlucmakiola.agendula.data.di.ExternalStore
|
||||
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||
import de.jeanlucmakiola.agendula.domain.toICal
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** How much one copy moved. */
|
||||
data class TransferCounts(val lists: Int, val tasks: Int, val alarms: Int)
|
||||
|
||||
/** The outcome of [ExternalImport.run]. */
|
||||
sealed interface TransferResult {
|
||||
/** The external store holds no list to copy — nothing was written. */
|
||||
data object NothingToCopy : TransferResult
|
||||
|
||||
data class Copied(val counts: TransferCounts) : TransferResult
|
||||
|
||||
/** Nothing landed: the transaction rolled back and the source is untouched. */
|
||||
data class Failed(val cause: Throwable) : TransferResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies an external provider's tasks (OpenTasks, tasks.org) into Agendula's own
|
||||
* Room store, once, when the user asks for it in Settings → Storage.
|
||||
*
|
||||
* This is the upgrade path 1.0.0 needs and [de.jeanlucmakiola.agendula.data
|
||||
* .tasks.legacy.OneShotImport] does not provide: that one moves a *bundled dmfs
|
||||
* provider's* SQLite file, which no released version ever shipped, so every
|
||||
* existing install's tasks are in a third-party provider instead. Without this
|
||||
* the only way onto the new store is to retype everything by hand.
|
||||
*
|
||||
* **A copy, not a sync, and deliberately one-directional.** The source is left
|
||||
* exactly as it is — whatever syncs it (DAVx5 and friends) keeps doing so, and
|
||||
* the two sets of rows drift apart from the moment this finishes. The reverse
|
||||
* direction is not offered: writing a *list* into a third-party provider means
|
||||
* impersonating its sync adapter, and the external store is already the one that
|
||||
* can sync.
|
||||
*
|
||||
* **What does not come across**, because the read seam is
|
||||
* [TasksDataSource.exportTasks] and that is shaped for iCalendar output:
|
||||
* per-occurrence `RECURRENCE-ID` overrides (a series arrives as its master plus
|
||||
* its rule, so an edited single occurrence reverts to the series' own values),
|
||||
* `EXDATE`, `CLASS`, `DURATION`, the per-task timezone, and a task's exact
|
||||
* `PRIORITY` digit — [de.jeanlucmakiola.agendula.domain.Priority] buckets 1–4 as
|
||||
* HIGH, so a `PRIORITY:3` lands as `1`. Nothing the app itself displays is lost.
|
||||
*
|
||||
* Task `uid`s *are* preserved, which is what lets these rows be re-attached to a
|
||||
* CalDAV collection once sync lands rather than duplicating server-side.
|
||||
*/
|
||||
@Singleton
|
||||
class ExternalImport @Inject constructor(
|
||||
@ExternalStore private val external: Provider<TasksDataSource>,
|
||||
private val resolver: ProviderResolver,
|
||||
private val database: TasksDatabase,
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Whether a copy has already succeeded. The UI uses this to stop offering the
|
||||
* action, because a second run would duplicate every row: the rows it writes
|
||||
* are ordinary tasks afterwards, indistinguishable from ones typed by hand, so
|
||||
* there is nothing to reconcile a re-run against.
|
||||
*/
|
||||
val hasRun: Flow<Boolean> = dataStore.data.map { it[TRANSFER_DONE] ?: false }
|
||||
|
||||
/**
|
||||
* What a copy would move, for the confirmation to name real numbers — or
|
||||
* `null` when there is no readable external provider to copy from.
|
||||
*
|
||||
* Counts masters, the same rows [run] writes, so the number the user agrees to
|
||||
* is the number they get.
|
||||
*/
|
||||
suspend fun preview(): TransferCounts? = withContext(io) {
|
||||
val provider = resolver.resolveExternal() ?: return@withContext null
|
||||
if (!resolver.hasPermission(provider)) return@withContext null
|
||||
runCatching {
|
||||
val source = external.get()
|
||||
val lists = source.taskLists()
|
||||
val tasks = lists.sumOf { source.exportTasks(it.id).size }
|
||||
TransferCounts(lists = lists.size, tasks = tasks, alarms = source.alarms().size)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the external store, then writes everything into Room in **one
|
||||
* transaction with verified counts** — the same discipline as the legacy
|
||||
* import, for the same reason: a partial copy is worse than none, because the
|
||||
* user cannot tell which half is missing.
|
||||
*
|
||||
* The flag is written only after the transaction commits. A crash in between
|
||||
* leaves the rows in place and the action still on offer, which duplicates on
|
||||
* a second run — the lesser of the two evils, since the alternative is
|
||||
* claiming a copy that never happened.
|
||||
*/
|
||||
suspend fun run(): TransferResult = withContext(io) {
|
||||
val snapshot = runCatching { read() }
|
||||
.getOrElse { return@withContext TransferResult.Failed(it) }
|
||||
if (snapshot.lists.isEmpty()) return@withContext TransferResult.NothingToCopy
|
||||
val counts = runCatching {
|
||||
database.runInTransaction<TransferCounts> {
|
||||
val baseline = tableCounts()
|
||||
val written = write(snapshot)
|
||||
verify(written, baseline)
|
||||
written
|
||||
}
|
||||
}.getOrElse { return@withContext TransferResult.Failed(it) }
|
||||
markDone()
|
||||
TransferResult.Copied(counts)
|
||||
}
|
||||
|
||||
// --- reading the external provider ----------------------------------------
|
||||
|
||||
private fun read(): Snapshot {
|
||||
val source = external.get()
|
||||
val lists = source.taskLists()
|
||||
return Snapshot(
|
||||
lists = lists,
|
||||
tasksByList = lists.associate { it.id to source.exportTasks(it.id) },
|
||||
alarms = source.alarms(),
|
||||
)
|
||||
}
|
||||
|
||||
// --- writing into Room ----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Provider row ids are the source's own, and Room mints its own on insert, so
|
||||
* `parentId` is remapped through the ids the inserts hand back. Tasks go in
|
||||
* with their parent cleared and a second pass sets it, because a parent may
|
||||
* sort after its child.
|
||||
*
|
||||
* Every list arrives as **device-only** (`account_id IS NULL`), including one
|
||||
* that sat under a CalDAV account in the provider: the account belongs to the
|
||||
* sync app, not to us, and claiming it here would suggest Agendula syncs it.
|
||||
*/
|
||||
private fun write(snapshot: Snapshot): TransferCounts {
|
||||
val listDao = database.taskLists()
|
||||
val taskDao = database.tasks()
|
||||
val alarmDao = database.alarms()
|
||||
|
||||
val listIds = snapshot.lists.associate { list ->
|
||||
list.id to listDao.insert(
|
||||
TaskListEntity(
|
||||
name = list.name,
|
||||
color = list.color,
|
||||
accountId = null,
|
||||
isVisible = list.isVisible,
|
||||
isSynced = false,
|
||||
owner = list.owner,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val taskIds = mutableMapOf<Long, Long>()
|
||||
val inserted = mutableListOf<Pair<ExportTask, TaskEntity>>()
|
||||
// A `RECURRENCE-ID` override reaches us as another master-shaped row
|
||||
// sharing its series' UID, which the unique index would reject and take
|
||||
// the whole copy down with it. A fresh uid costs that row nothing it
|
||||
// still has.
|
||||
val seen = mutableSetOf<Pair<Long, String>>()
|
||||
|
||||
for ((sourceListId, tasks) in snapshot.tasksByList) {
|
||||
val listId = listIds[sourceListId] ?: continue
|
||||
for (task in tasks) {
|
||||
val uid = task.uid?.takeIf { seen.add(listId to it) } ?: UUID.randomUUID().toString()
|
||||
val entity = task.toEntity(listId, uid)
|
||||
val newId = taskDao.insert(entity)
|
||||
taskIds[task.taskId] = newId
|
||||
inserted += task to entity.copy(id = newId)
|
||||
}
|
||||
}
|
||||
|
||||
for ((task, entity) in inserted) {
|
||||
val parentId = task.parentId?.let(taskIds::get) ?: continue
|
||||
taskDao.update(entity.copy(parentId = parentId))
|
||||
}
|
||||
|
||||
var alarmCount = 0
|
||||
for ((sourceTaskId, reminder) in snapshot.alarms) {
|
||||
val taskId = taskIds[sourceTaskId] ?: continue
|
||||
alarmDao.insert(reminder.toEntity(taskId))
|
||||
alarmCount++
|
||||
}
|
||||
|
||||
return TransferCounts(lists = listIds.size, tasks = taskIds.size, alarms = alarmCount)
|
||||
}
|
||||
|
||||
private fun ExportTask.toEntity(listId: Long, uid: String) = TaskEntity(
|
||||
listId = listId,
|
||||
uid = uid,
|
||||
title = title,
|
||||
description = description,
|
||||
location = location,
|
||||
url = url,
|
||||
status = status,
|
||||
percentComplete = percentComplete,
|
||||
completedAt = completedAt,
|
||||
priority = priority.toICal(),
|
||||
dtstart = start,
|
||||
due = due,
|
||||
isAllDay = isAllDay,
|
||||
rrule = rrule,
|
||||
rdate = rdate,
|
||||
createdAt = created,
|
||||
lastModified = lastModified,
|
||||
)
|
||||
|
||||
private fun TaskReminder.toEntity(taskId: Long) = TaskAlarmEntity(
|
||||
taskId = taskId,
|
||||
minutesBefore = minutesBefore,
|
||||
reference = if (fromStart) AlarmReference.START else AlarmReference.DUE,
|
||||
)
|
||||
|
||||
private fun verify(written: TransferCounts, before: TransferCounts) {
|
||||
val after = tableCounts()
|
||||
check(after.lists - before.lists == written.lists) {
|
||||
"list count mismatch: ${after.lists - before.lists} != ${written.lists}"
|
||||
}
|
||||
check(after.tasks - before.tasks == written.tasks) {
|
||||
"task count mismatch: ${after.tasks - before.tasks} != ${written.tasks}"
|
||||
}
|
||||
check(after.alarms - before.alarms == written.alarms) {
|
||||
"alarm count mismatch: ${after.alarms - before.alarms} != ${written.alarms}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun tableCounts() = TransferCounts(
|
||||
lists = count("task_lists"),
|
||||
tasks = count("tasks"),
|
||||
alarms = count("task_alarms"),
|
||||
)
|
||||
|
||||
private fun count(table: String): Int =
|
||||
database.query("SELECT COUNT(*) FROM $table", null).use {
|
||||
if (it.moveToFirst()) it.getInt(0) else 0
|
||||
}
|
||||
|
||||
private suspend fun markDone() {
|
||||
dataStore.edit { it[TRANSFER_DONE] = true }
|
||||
}
|
||||
|
||||
/** Clears the guard so the action is offered again. Test and support hook. */
|
||||
suspend fun clearCompletion() {
|
||||
dataStore.edit { it.remove(TRANSFER_DONE) }
|
||||
}
|
||||
|
||||
private class Snapshot(
|
||||
val lists: List<TaskList>,
|
||||
val tasksByList: Map<Long, List<ExportTask>>,
|
||||
val alarms: Map<Long, TaskReminder>,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val TRANSFER_DONE = booleanPreferencesKey("external_copy_done")
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.ui.common.OnResume
|
||||
|
||||
@@ -36,7 +36,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.ProviderLogo
|
||||
|
||||
@@ -65,7 +65,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
|
||||
@@ -78,7 +78,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.floret.time.DayWindow
|
||||
|
||||
@@ -47,7 +47,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.data.export.ExportFailure
|
||||
|
||||
@@ -72,7 +72,7 @@ import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.domain.Priority
|
||||
|
||||
@@ -8,7 +8,7 @@ import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
|
||||
@@ -26,7 +26,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.ui.export.ExportOutcome
|
||||
|
||||
@@ -20,7 +20,7 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.add.AddAccountScreen
|
||||
|
||||
@@ -38,7 +38,7 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.agendula.ui.accounts.AccountDetailScreen
|
||||
|
||||
@@ -7,12 +7,17 @@ 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.reminders.ReminderScheduler
|
||||
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.legacy.OneShotImport
|
||||
import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure
|
||||
import de.jeanlucmakiola.agendula.data.tasks.transfer.ExternalImport
|
||||
import de.jeanlucmakiola.agendula.data.tasks.transfer.TransferCounts
|
||||
import de.jeanlucmakiola.agendula.data.tasks.transfer.TransferResult
|
||||
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
@@ -46,6 +51,38 @@ data class StorageUiState(
|
||||
val external: TaskProvider? = null,
|
||||
/** That provider's own app name, for a row that names what it is switching to. */
|
||||
val externalLabel: String? = null,
|
||||
/**
|
||||
* Whether copying that provider's tasks into our own store is still on offer:
|
||||
* a provider is installed and permitted, and no copy has succeeded yet.
|
||||
*/
|
||||
val canCopyFromExternal: Boolean = false,
|
||||
/**
|
||||
* Whether the one-shot legacy import failed and is still retryable. Almost
|
||||
* always false: the bundled provider it reads from never shipped in a release.
|
||||
*/
|
||||
val legacyImportFailed: Boolean = false,
|
||||
)
|
||||
|
||||
/** How the last copy ended — kept on screen rather than flashed past. */
|
||||
sealed interface TransferOutcome {
|
||||
data class Copied(val counts: TransferCounts) : TransferOutcome
|
||||
data object NothingToCopy : TransferOutcome
|
||||
data object Failed : TransferOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* The copy-from-external flow: a confirmation that names real numbers, then the
|
||||
* run, then a receipt that stays put.
|
||||
*
|
||||
* [preview] is null while the counts are still being read — the dialog opens
|
||||
* first and fills in, because counting means querying every list in the provider
|
||||
* and that is not instant on a big store.
|
||||
*/
|
||||
data class TransferUiState(
|
||||
val confirming: Boolean = false,
|
||||
val preview: TransferCounts? = null,
|
||||
val running: Boolean = false,
|
||||
val outcome: TransferOutcome? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -57,6 +94,9 @@ class SettingsViewModel @Inject constructor(
|
||||
private val prefs: SettingsPrefs,
|
||||
private val resolver: ProviderResolver,
|
||||
private val environment: ProviderEnvironment,
|
||||
private val externalImport: ExternalImport,
|
||||
private val oneShotImport: OneShotImport,
|
||||
private val reminderScheduler: ReminderScheduler,
|
||||
@IoDispatcher io: CoroutineDispatcher,
|
||||
repository: TasksRepository,
|
||||
) : ViewModel() {
|
||||
@@ -81,7 +121,12 @@ class SettingsViewModel @Inject constructor(
|
||||
// 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, _ ->
|
||||
combine(
|
||||
prefs.storageMode,
|
||||
providerProbe,
|
||||
externalImport.hasRun,
|
||||
oneShotImport.lastAttemptFailed,
|
||||
) { stored, _, copied, legacyFailed ->
|
||||
val external = resolver.resolveExternal()
|
||||
StorageUiState(
|
||||
// No stored choice is the normal state; show what autoMode resolves
|
||||
@@ -89,6 +134,10 @@ class SettingsViewModel @Inject constructor(
|
||||
mode = stored ?: resolver.autoMode(),
|
||||
external = external,
|
||||
externalLabel = external?.packageName?.let(environment::appLabel),
|
||||
canCopyFromExternal = !copied &&
|
||||
external != null &&
|
||||
resolver.hasPermission(external),
|
||||
legacyImportFailed = legacyFailed,
|
||||
)
|
||||
}.flowOn(io).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
@@ -97,6 +146,69 @@ class SettingsViewModel @Inject constructor(
|
||||
|
||||
fun setStorageMode(mode: StorageMode) = viewModelScope.launch { prefs.setStorageMode(mode) }
|
||||
|
||||
// --- copying an external provider's tasks into our own store ---------------
|
||||
|
||||
private val transferState = MutableStateFlow(TransferUiState())
|
||||
|
||||
val transfer: StateFlow<TransferUiState> = transferState
|
||||
|
||||
/** Open the confirmation and start counting what a copy would move. */
|
||||
fun startCopyFromExternal() {
|
||||
if (transferState.value.running) return
|
||||
transferState.update { it.copy(confirming = true, preview = null, outcome = null) }
|
||||
viewModelScope.launch {
|
||||
val counts = externalImport.preview()
|
||||
// The dialog may already be gone — dismissing while the count runs is
|
||||
// the normal way out of a store too big to count quickly.
|
||||
transferState.update { if (it.confirming) it.copy(preview = counts) else it }
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissCopyFromExternal() = transferState.update { it.copy(confirming = false) }
|
||||
|
||||
/**
|
||||
* Run the copy, then re-arm reminders: the tasks that just landed carry their
|
||||
* own alarm rows, and nothing else would notice them — the scheduler is driven
|
||||
* by explicit syncs, not by a store observer.
|
||||
*/
|
||||
fun confirmCopyFromExternal() {
|
||||
if (transferState.value.running) return
|
||||
transferState.update { it.copy(confirming = false, running = true, outcome = null) }
|
||||
viewModelScope.launch {
|
||||
val result = externalImport.run()
|
||||
if (result is TransferResult.Copied) runCatching { reminderScheduler.sync() }
|
||||
transferState.update {
|
||||
it.copy(
|
||||
running = false,
|
||||
outcome = when (result) {
|
||||
is TransferResult.Copied -> TransferOutcome.Copied(result.counts)
|
||||
TransferResult.NothingToCopy -> TransferOutcome.NothingToCopy
|
||||
is TransferResult.Failed -> TransferOutcome.Failed
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry the legacy import against the archived `tasks.db.imported`. Truncates
|
||||
* and replaces rather than merging, so tapping twice cannot double-import; on
|
||||
* success the flag clears and the row offering this disappears with it.
|
||||
*/
|
||||
fun retryLegacyImport() {
|
||||
if (retryingLegacyImport.value) return
|
||||
retryingLegacyImport.value = true
|
||||
viewModelScope.launch {
|
||||
runCatching { oneShotImport.reimportFromArchive() }
|
||||
runCatching { reminderScheduler.sync() }
|
||||
retryingLegacyImport.value = false
|
||||
}
|
||||
}
|
||||
|
||||
private val retryingLegacyImport = MutableStateFlow(false)
|
||||
|
||||
val legacyImportRetrying: StateFlow<Boolean> = retryingLegacyImport
|
||||
|
||||
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) }
|
||||
|
||||
@@ -5,22 +5,34 @@ import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.Apps
|
||||
import androidx.compose.material.icons.rounded.MoveDown
|
||||
import androidx.compose.material.icons.rounded.PhoneAndroid
|
||||
import androidx.compose.material.icons.rounded.SwapHoriz
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
@@ -28,6 +40,7 @@ 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.data.tasks.transfer.TransferCounts
|
||||
import de.jeanlucmakiola.agendula.ui.common.OnResume
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
@@ -47,8 +60,13 @@ internal fun StorageScreen(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val storage by viewModel.storage.collectAsStateWithLifecycle()
|
||||
val transfer by viewModel.transfer.collectAsStateWithLifecycle()
|
||||
val retryingLegacyImport by viewModel.legacyImportRetrying.collectAsStateWithLifecycle()
|
||||
var showPicker by remember { mutableStateOf(false) }
|
||||
var denied by remember { mutableStateOf(false) }
|
||||
// The store the user picked and has not yet confirmed. Switching stores moves
|
||||
// nothing, so it is worth one dialog rather than an empty app and a guess.
|
||||
var pendingMode by remember { mutableStateOf<StorageMode?>(null) }
|
||||
|
||||
// The mode is committed only once the grant is in — switching first drops the
|
||||
// user on the app-wide permission gate.
|
||||
@@ -80,6 +98,19 @@ internal fun StorageScreen(
|
||||
}
|
||||
},
|
||||
)
|
||||
if (storage?.canCopyFromExternal == true) {
|
||||
GroupedRow(
|
||||
title = stringResource(
|
||||
R.string.settings_copy_from_external,
|
||||
storage?.let { externalTitle(it.external, it.externalLabel) }.orEmpty(),
|
||||
),
|
||||
summary = stringResource(R.string.settings_copy_from_external_hint),
|
||||
position = Position.Middle,
|
||||
leading = { Icon(Icons.Rounded.MoveDown, contentDescription = null) },
|
||||
// Inert while a copy is in flight; the receipt below says so.
|
||||
onClick = if (transfer.running) null else viewModel::startCopyFromExternal,
|
||||
)
|
||||
}
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_export),
|
||||
summary = stringResource(R.string.settings_export_hint),
|
||||
@@ -87,6 +118,26 @@ internal fun StorageScreen(
|
||||
onClick = onOpenExport,
|
||||
)
|
||||
|
||||
TransferStatus(transfer)
|
||||
|
||||
// Only ever shown to someone whose upgrade import failed — their tasks are
|
||||
// still in the archived file, and this is the one way back to them.
|
||||
if (storage?.legacyImportFailed == true) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_legacy_import_failed),
|
||||
summary = stringResource(
|
||||
if (retryingLegacyImport) {
|
||||
R.string.settings_legacy_import_retrying
|
||||
} else {
|
||||
R.string.settings_legacy_import_failed_hint
|
||||
},
|
||||
),
|
||||
position = Position.Alone,
|
||||
onClick = if (retryingLegacyImport) null else viewModel::retryLegacyImport,
|
||||
)
|
||||
}
|
||||
|
||||
if (denied) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
GroupedRow(
|
||||
@@ -102,7 +153,21 @@ internal fun StorageScreen(
|
||||
if (showPicker) {
|
||||
StorePicker(
|
||||
storage = state,
|
||||
onSelect = { mode ->
|
||||
// Picking the store already in use is not a switch — no dialog.
|
||||
onSelect = { mode -> if (mode != state.mode) pendingMode = mode },
|
||||
onDismiss = { showPicker = false },
|
||||
)
|
||||
}
|
||||
|
||||
pendingMode?.let { mode ->
|
||||
SwitchStoreDialog(
|
||||
target = mode,
|
||||
otherStore = when (mode) {
|
||||
StorageMode.OWN -> externalTitle(state.external, state.externalLabel)
|
||||
StorageMode.EXTERNAL -> stringResource(R.string.settings_store_own)
|
||||
},
|
||||
onConfirm = {
|
||||
pendingMode = null
|
||||
val external = state.external
|
||||
if (mode == StorageMode.EXTERNAL && external != null) {
|
||||
// Asked even when the grant looks held: an already-granted
|
||||
@@ -114,12 +179,146 @@ internal fun StorageScreen(
|
||||
viewModel.setStorageMode(mode)
|
||||
}
|
||||
},
|
||||
onDismiss = { showPicker = false },
|
||||
onDismiss = { pendingMode = null },
|
||||
)
|
||||
}
|
||||
|
||||
if (transfer.confirming) {
|
||||
CopyFromExternalDialog(
|
||||
source = externalTitle(state.external, state.externalLabel),
|
||||
counts = transfer.preview,
|
||||
onConfirm = viewModel::confirmCopyFromExternal,
|
||||
onDismiss = viewModel::dismissCopyFromExternal,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The switch itself, behind a confirm. Neither store hands its rows to the other
|
||||
* when the mode changes — the tasks stay where they were written — so the app
|
||||
* looks emptied to anyone who expected a move. Saying so once is cheaper than
|
||||
* the support thread.
|
||||
*/
|
||||
@Composable
|
||||
private fun SwitchStoreDialog(
|
||||
target: StorageMode,
|
||||
otherStore: String,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = { Icon(Icons.Rounded.SwapHoriz, contentDescription = null) },
|
||||
title = { Text(stringResource(R.string.settings_store_switch_title)) },
|
||||
text = {
|
||||
Text(
|
||||
stringResource(
|
||||
when (target) {
|
||||
StorageMode.OWN -> R.string.settings_store_switch_to_own
|
||||
StorageMode.EXTERNAL -> R.string.settings_store_switch_to_external
|
||||
},
|
||||
otherStore,
|
||||
),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) { Text(stringResource(R.string.settings_store_switch_confirm)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The copy, behind a confirm that names real numbers — it is irreversible in the
|
||||
* only sense that matters: nothing merges the result back, so running it twice
|
||||
* would leave two of everything. Confirm stays disabled until the count lands.
|
||||
*/
|
||||
@Composable
|
||||
private fun CopyFromExternalDialog(
|
||||
source: String,
|
||||
counts: TransferCounts?,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = { Icon(Icons.Rounded.MoveDown, contentDescription = null) },
|
||||
title = { Text(stringResource(R.string.settings_copy_confirm_title)) },
|
||||
text = {
|
||||
Text(
|
||||
text = when {
|
||||
counts == null -> stringResource(R.string.settings_copy_counting)
|
||||
counts.tasks == 0 -> stringResource(R.string.settings_copy_confirm_empty, source)
|
||||
else -> pluralStringResource(
|
||||
R.plurals.settings_copy_confirm_message,
|
||||
counts.tasks,
|
||||
counts.tasks,
|
||||
source,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm, enabled = counts != null && counts.tasks > 0) {
|
||||
Text(stringResource(R.string.settings_copy_confirm_action))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** The running spinner, then whatever the last copy ended as — it stays put. */
|
||||
@Composable
|
||||
private fun TransferStatus(state: TransferUiState) {
|
||||
val outcome = state.outcome
|
||||
when {
|
||||
state.running -> Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
CircularProgressIndicator(Modifier.size(18.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.settings_copy_running),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
outcome is TransferOutcome.Copied -> StatusText(
|
||||
text = pluralStringResource(
|
||||
R.plurals.settings_copy_done,
|
||||
outcome.counts.tasks,
|
||||
outcome.counts.tasks,
|
||||
outcome.counts.lists,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
outcome is TransferOutcome.NothingToCopy -> StatusText(
|
||||
text = stringResource(R.string.settings_copy_nothing),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
outcome is TransferOutcome.Failed -> StatusText(
|
||||
text = stringResource(R.string.settings_copy_failed),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusText(text: String, color: Color) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = color,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -87,7 +87,7 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import de.jeanlucmakiola.floret.time.DayWindow
|
||||
@@ -204,7 +204,7 @@ fun TaskListScreen(
|
||||
},
|
||||
bottomBar = {
|
||||
if (showBottomAddBar) {
|
||||
listId?.let { id -> QuickAddBar(onAdd = { title -> viewModel.quickAdd(title, id) }) }
|
||||
QuickAddBar(onAdd = { title -> viewModel.quickAdd(title, listId) })
|
||||
}
|
||||
},
|
||||
) { inner ->
|
||||
|
||||
@@ -15,9 +15,6 @@
|
||||
<string name="smart_completed">Abgeschlossen</string>
|
||||
<string name="task_detail_title">Aufgabe</string>
|
||||
<string name="task_detail_not_found">Diese Aufgabe ist nicht mehr verfügbar.</string>
|
||||
<string name="edit_task_title">Aufgabe bearbeiten</string>
|
||||
<string name="new_task_title">Neue Aufgabe</string>
|
||||
<string name="field_title">Titel</string>
|
||||
<string name="edit_save_failed">Konnte nicht gespeichert werden. Versuche es erneut.</string>
|
||||
<string name="add_task_hint">Aufgabe hinzufügen</string>
|
||||
<string name="add_subtask_hint">Unteraufgabe hinzufügen</string>
|
||||
@@ -36,11 +33,8 @@
|
||||
<string name="priority_low">Niedrig</string>
|
||||
<string name="priority_medium">Mittel</string>
|
||||
<string name="priority_high">Hoch</string>
|
||||
<string name="detail_mark_complete">Als abgeschlossen markieren</string>
|
||||
<string name="detail_mark_incomplete">Als nicht abgeschlossen markieren</string>
|
||||
<string name="detail_list">Liste</string>
|
||||
<string name="detail_due">Fällig</string>
|
||||
<string name="detail_start">Beginn</string>
|
||||
<string name="detail_priority">Priorität</string>
|
||||
<string name="detail_progress">Fortschritt</string>
|
||||
<string name="detail_subtasks">Unteraufgaben</string>
|
||||
@@ -98,7 +92,6 @@
|
||||
<string name="smart_overdue">Überfällig</string>
|
||||
<string name="smart_upcoming">Anstehend</string>
|
||||
<string name="smart_all">Alle</string>
|
||||
<string name="open_count">%1$d offen</string>
|
||||
<string name="home_today_progress">%1$d von %2$d erledigt</string>
|
||||
<string name="home_today_remaining">%1$d übrig</string>
|
||||
<string name="home_today_all_done">Alles erledigt 🎉</string>
|
||||
@@ -108,7 +101,6 @@
|
||||
<string name="home_due_tomorrow">Morgen</string>
|
||||
<string name="home_search_hint">Aufgaben suchen</string>
|
||||
<string name="home_search_clear">Suche zurücksetzen</string>
|
||||
<string name="home_search_close">Suche schließen</string>
|
||||
<string name="home_search_empty">Keine Aufgaben passen zu „%1$s“</string>
|
||||
<string name="reminder_due_at">Fälligkeiten %1$s</string>
|
||||
<string name="reminder_channel_name">Aufgaben Erinnerungen</string>
|
||||
@@ -118,8 +110,6 @@
|
||||
<string name="onboarding_permission_title">Erlaube Zugriff auf deine Aufgaben</string>
|
||||
<string name="onboarding_permission_body">Agendula benötigt die Berechtigung, deine Aufgaben zu lesen und zu schreiben. Das ist die einzige Berechtigung, die es jemals anfragt.</string>
|
||||
<string name="onboarding_permission_button">Zugriff gewähren</string>
|
||||
<string name="onboarding_install_opentasks">Installiere OpenTasks</string>
|
||||
<string name="onboarding_install_tasksorg">Installiere tasks.org</string>
|
||||
<string name="reminder_onboarding_title">Verpasse nichts, was fällig ist</string>
|
||||
<string name="reminder_onboarding_body">Aufgaben-Apps senden selbst keine Erinnerungen, daher liefert Agendula sie für dich. Schalte sie ein, um eine Benachrichtigung zu erhalten, wenn eine Aufgabe fällig ist.</string>
|
||||
<string name="reminder_onboarding_enable_button">Erinnerungen aktivieren</string>
|
||||
@@ -162,7 +152,6 @@
|
||||
<string name="settings_exact_alarms">Exakter Zeitpunkt</string>
|
||||
<string name="settings_exact_alarms_allowed">Die Erinnerungen erfolgen zum exakten Zeitpunkt</string>
|
||||
<string name="settings_exact_alarms_blocked">Blockiert – tippe, um exakte Erinnerungen zu erlauben</string>
|
||||
<string name="settings_section_tasks">Aufgaben</string>
|
||||
<string name="settings_default_list">Standard-Liste</string>
|
||||
<string name="settings_default_list_first">Erste verfügbare Felder</string>
|
||||
<string name="settings_add_subtask_row">Zeile \"Unteraufgaben hinzufügen\" anzeigen</string>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Agendula</string>
|
||||
<string name="app_tagline">Um app de tarefas em Material 3 Expressive. \nFlorescendo.</string>
|
||||
<string name="task_untitled">Tarefa sem título</string>
|
||||
<string name="back">Voltar</string>
|
||||
<string name="save">Salvar</string>
|
||||
@@ -14,9 +13,6 @@
|
||||
<string name="task_detail_title">Tarefa</string>
|
||||
<string name="task_detail_not_found">Esta tarefa não existe mais.</string>
|
||||
<string name="edit">Editar</string>
|
||||
<string name="edit_task_title">Editar tarefa</string>
|
||||
<string name="new_task_title">Nova tarefa</string>
|
||||
<string name="field_title">Título</string>
|
||||
<string name="field_description">Descrição</string>
|
||||
<string name="edit_save_failed">Não foi possível salvar. Tente novamente.</string>
|
||||
<string name="add_task_hint">Adicionar tarefa</string>
|
||||
@@ -37,11 +33,8 @@
|
||||
<string name="priority_low">Baixa</string>
|
||||
<string name="priority_medium">Média</string>
|
||||
<string name="priority_high">Alta</string>
|
||||
<string name="detail_mark_complete">Marcar como concluído</string>
|
||||
<string name="detail_mark_incomplete">Marcar como não concluído</string>
|
||||
<string name="detail_list">Lista</string>
|
||||
<string name="detail_due">Prazo</string>
|
||||
<string name="detail_start">Inicia</string>
|
||||
<string name="detail_priority">Prioridade</string>
|
||||
<string name="detail_progress">Progresso</string>
|
||||
<string name="detail_subtasks">Subtarefas</string>
|
||||
@@ -100,7 +93,6 @@
|
||||
<string name="smart_overdue">Prazo expirado</string>
|
||||
<string name="smart_upcoming">A seguir</string>
|
||||
<string name="smart_all">Tudo</string>
|
||||
<string name="open_count">%1$d abertos</string>
|
||||
<string name="home_today_progress">%1$d de %2$d concluídos</string>
|
||||
<string name="home_today_remaining">%1$d restantes</string>
|
||||
<string name="home_today_all_done">Tudo pronto 🎉</string>
|
||||
@@ -110,7 +102,6 @@
|
||||
<string name="home_due_tomorrow">Amanhã</string>
|
||||
<string name="home_search_hint">Buscar tarefas</string>
|
||||
<string name="home_search_clear">Limpar buscas</string>
|
||||
<string name="home_search_close">Fechar busca</string>
|
||||
<string name="home_search_empty">Nenhuma tarefa corresponde a \"%1$s\"</string>
|
||||
<string name="reminder_due_at">Termina em %1$s</string>
|
||||
<string name="reminder_channel_name">Lembretes de tarefas</string>
|
||||
@@ -120,8 +111,6 @@
|
||||
<string name="onboarding_permission_title">Permitir acesso às suas tarefas</string>
|
||||
<string name="onboarding_permission_body">O Agendula precisa de permissão para ler e salvar suas tarefas. É tudo o que ele pede.</string>
|
||||
<string name="onboarding_permission_button">Conceder acesso às tarefas</string>
|
||||
<string name="onboarding_install_opentasks">Instalar OpenTasks</string>
|
||||
<string name="onboarding_install_tasksorg">Instalar tasks.org</string>
|
||||
<string name="reminder_onboarding_title">Nunca perca seus prazos</string>
|
||||
<string name="reminder_onboarding_body">Apps de tarefas não enviam lembretes sozinhos, então o Agendula os entrega para você. Ative para receber uma notificação de quando uma tarefa estiver no prazo.</string>
|
||||
<string name="reminder_onboarding_enable_button">Ativar lembretes</string>
|
||||
@@ -166,7 +155,6 @@
|
||||
<string name="settings_exact_alarms">Na hora</string>
|
||||
<string name="settings_exact_alarms_allowed">Lembretes disparam na mesma hora</string>
|
||||
<string name="settings_exact_alarms_blocked">Bloqueado — toque para permitir lembretes na hora</string>
|
||||
<string name="settings_section_tasks">Tarefas</string>
|
||||
<string name="settings_default_list">Lista padrão</string>
|
||||
<string name="settings_default_list_first">Primeira lista disponível</string>
|
||||
<string name="settings_add_subtask_row">Mostrar uma linha \"adicionar subtarefa\"</string>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<resources>
|
||||
<!-- Seed color: warm mauve. Material 3 derives Light & Dark schemes from this. -->
|
||||
<color name="seed">#FF7A5C6B</color>
|
||||
<!-- Adaptive icon background -->
|
||||
<color name="ic_launcher_background">#FF7A5C6B</color>
|
||||
</resources>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<resources>
|
||||
<string name="app_name">Agendula</string>
|
||||
<string name="app_tagline">A modern Material 3 Expressive task app.\nComing into bloom.</string>
|
||||
|
||||
<!-- Generic -->
|
||||
<string name="task_untitled">Untitled task</string>
|
||||
@@ -21,9 +20,6 @@
|
||||
<string name="edit">Edit</string>
|
||||
|
||||
<!-- Task edit -->
|
||||
<string name="edit_task_title">Edit task</string>
|
||||
<string name="new_task_title">New task</string>
|
||||
<string name="field_title">Title</string>
|
||||
<string name="field_description">Description</string>
|
||||
<string name="edit_save_failed">Could not save. Try again.</string>
|
||||
|
||||
@@ -52,11 +48,8 @@
|
||||
<string name="priority_high">High</string>
|
||||
|
||||
<!-- Task detail -->
|
||||
<string name="detail_mark_complete">Mark complete</string>
|
||||
<string name="detail_mark_incomplete">Mark not complete</string>
|
||||
<string name="detail_list">List</string>
|
||||
<string name="detail_due">Due</string>
|
||||
<string name="detail_start">Starts</string>
|
||||
<string name="detail_priority">Priority</string>
|
||||
<string name="detail_progress">Progress</string>
|
||||
<string name="detail_subtasks">Subtasks</string>
|
||||
@@ -160,7 +153,6 @@
|
||||
<string name="smart_overdue">Overdue</string>
|
||||
<string name="smart_upcoming">Upcoming</string>
|
||||
<string name="smart_all">All</string>
|
||||
<string name="open_count">%1$d open</string>
|
||||
|
||||
<!-- Home: Today progress hero -->
|
||||
<string name="home_today_progress">%1$d of %2$d done</string>
|
||||
@@ -176,7 +168,6 @@
|
||||
<!-- Home: search -->
|
||||
<string name="home_search_hint">Search tasks</string>
|
||||
<string name="home_search_clear">Clear search</string>
|
||||
<string name="home_search_close">Close search</string>
|
||||
<string name="home_search_empty">No tasks match “%1$s”</string>
|
||||
|
||||
<!-- Reminders -->
|
||||
@@ -190,8 +181,6 @@
|
||||
<string name="onboarding_permission_title">Allow access to your tasks</string>
|
||||
<string name="onboarding_permission_body">Agendula needs permission to read and write your tasks. That\'s the only thing it ever asks for.</string>
|
||||
<string name="onboarding_permission_button">Grant task access</string>
|
||||
<string name="onboarding_install_opentasks">Install OpenTasks</string>
|
||||
<string name="onboarding_install_tasksorg">Install tasks.org</string>
|
||||
|
||||
<!-- First run: welcome -->
|
||||
<string name="onboarding_welcome_title">Everything you meant to do</string>
|
||||
@@ -304,7 +293,6 @@
|
||||
<string name="settings_exact_alarms">Exact timing</string>
|
||||
<string name="settings_exact_alarms_allowed">Reminders fire at the exact time</string>
|
||||
<string name="settings_exact_alarms_blocked">Blocked — tap to allow exact reminders</string>
|
||||
<string name="settings_section_tasks">Tasks</string>
|
||||
<string name="settings_default_list">Default list</string>
|
||||
<string name="settings_default_list_first">First available list</string>
|
||||
<string name="settings_add_subtask_row">Show \"add a subtask\" row</string>
|
||||
@@ -318,7 +306,7 @@
|
||||
<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_task_store_hint">Each store keeps its own tasks. Switching does not move them across — copy them over first, or export them.</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>
|
||||
@@ -326,6 +314,32 @@
|
||||
<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>
|
||||
|
||||
<!-- Switching stores, and copying tasks from another task app into ours -->
|
||||
<string name="settings_store_switch_title">Switch task store?</string>
|
||||
<string name="settings_store_switch_to_own">Your tasks stay in %1$s — they are not moved. Agendula will show its own storage, which starts out empty unless you copy them over.</string>
|
||||
<string name="settings_store_switch_to_external">Your tasks stay in %1$s — they are not moved. Agendula will show the other app\'s tasks instead.</string>
|
||||
<string name="settings_store_switch_confirm">Switch</string>
|
||||
<string name="settings_copy_from_external">Copy tasks from %1$s</string>
|
||||
<string name="settings_copy_from_external_hint">Bring them into Agendula\'s own storage. A one-time copy — the originals stay where they are.</string>
|
||||
<string name="settings_copy_confirm_title">Copy tasks over?</string>
|
||||
<string name="settings_copy_counting">Counting what there is to copy…</string>
|
||||
<string name="settings_copy_confirm_empty">%1$s holds no tasks to copy.</string>
|
||||
<plurals name="settings_copy_confirm_message">
|
||||
<item quantity="one">%1$d task from %2$s will be copied into Agendula\'s own storage. The originals stay where they are, and the two stop matching from here on — so this is offered only once.</item>
|
||||
<item quantity="other">%1$d tasks from %2$s will be copied into Agendula\'s own storage. The originals stay where they are, and the two stop matching from here on — so this is offered only once.</item>
|
||||
</plurals>
|
||||
<string name="settings_copy_confirm_action">Copy</string>
|
||||
<string name="settings_copy_running">Copying tasks…</string>
|
||||
<plurals name="settings_copy_done">
|
||||
<item quantity="one">Copied %1$d task into %2$d list. Pick “On this device” above to see them.</item>
|
||||
<item quantity="other">Copied %1$d tasks into %2$d lists. Pick “On this device” above to see them.</item>
|
||||
</plurals>
|
||||
<string name="settings_copy_nothing">There was nothing to copy</string>
|
||||
<string name="settings_copy_failed">The tasks could not be copied. Nothing was changed — your originals are untouched.</string>
|
||||
<string name="settings_legacy_import_failed">Your earlier tasks could not be moved</string>
|
||||
<string name="settings_legacy_import_failed_hint">They are still saved and nothing was lost. Tap to try moving them again.</string>
|
||||
<string name="settings_legacy_import_retrying">Moving them now…</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>
|
||||
|
||||
Reference in New Issue
Block a user