From a8bc3f0084fa21e6cd49c84bed20519b9a02ba64 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 28 Oct 2026 12:18:04 +0100 Subject: [PATCH] feat: let the user name an account (#105) --- .../4.json | 553 ++++++++++++++++++ .../tasks/room/TasksDatabaseMigrationTest.kt | 15 + .../agendula/data/di/SyncModule.kt | 3 +- .../agendula/data/sync/AccountRepository.kt | 5 + .../agendula/data/sync/AccountSyncRequests.kt | 20 + .../agendula/data/sync/SyncTrigger.kt | 6 +- .../ui/accounts/AccountDetailScreen.kt | 53 ++ .../agendula/ui/accounts/AccountIdentity.kt | 10 +- .../agendula/ui/accounts/AccountsViewModel.kt | 4 + .../agendula/data/di/DataModule.kt | 2 +- .../agendula/data/sync/SyncRequests.kt | 2 +- .../agendula/data/tasks/room/AccountDao.kt | 3 + .../agendula/data/tasks/room/Entities.kt | 2 + .../agendula/data/tasks/room/Projections.kt | 2 + .../data/tasks/room/RoomTaskMapper.kt | 4 +- .../agendula/data/tasks/room/TaskDao.kt | 6 +- .../agendula/data/tasks/room/TaskListDao.kt | 4 +- .../agendula/data/tasks/room/TasksDatabase.kt | 9 +- .../agendula/ui/imports/ImportViewModel.kt | 2 +- app/src/main/res/values/strings.xml | 2 + .../data/tasks/room/RoomTaskMapperTest.kt | 12 + 21 files changed, 701 insertions(+), 18 deletions(-) create mode 100644 app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/4.json create mode 100644 app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountSyncRequests.kt diff --git a/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/4.json b/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/4.json new file mode 100644 index 0000000..5502a50 --- /dev/null +++ b/app/schemas/de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase/4.json @@ -0,0 +1,553 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "47f3bedbfbf4ddd1db32864c718de83a", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `display_name` TEXT NOT NULL, `principal_url` TEXT, `home_set_url` TEXT, `username` TEXT, `last_sync_at` INTEGER, `last_sync_error` TEXT, `label` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "principalUrl", + "columnName": "principal_url", + "affinity": "TEXT" + }, + { + "fieldPath": "homeSetUrl", + "columnName": "home_set_url", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSyncAt", + "columnName": "last_sync_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastSyncError", + "columnName": "last_sync_error", + "affinity": "TEXT" + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "task_lists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER NOT NULL, `account_id` INTEGER, `is_visible` INTEGER NOT NULL DEFAULT 1, `is_synced` INTEGER NOT NULL DEFAULT 1, `owner` TEXT, `is_read_only` INTEGER NOT NULL DEFAULT 0, `sort_order` INTEGER NOT NULL DEFAULT 0, `href` TEXT, `ctag` TEXT, `sync_token` TEXT, `is_dirty` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`account_id`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "account_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "isVisible", + "columnName": "is_visible", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "isSynced", + "columnName": "is_synced", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "owner", + "columnName": "owner", + "affinity": "TEXT" + }, + { + "fieldPath": "isReadOnly", + "columnName": "is_read_only", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "href", + "columnName": "href", + "affinity": "TEXT" + }, + { + "fieldPath": "ctag", + "columnName": "ctag", + "affinity": "TEXT" + }, + { + "fieldPath": "syncToken", + "columnName": "sync_token", + "affinity": "TEXT" + }, + { + "fieldPath": "isDirty", + "columnName": "is_dirty", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_task_lists_account_id", + "unique": false, + "columnNames": [ + "account_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_task_lists_account_id` ON `${TABLE_NAME}` (`account_id`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "account_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "tasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `list_id` INTEGER NOT NULL, `uid` TEXT NOT NULL, `href` TEXT, `etag` TEXT, `title` TEXT, `description` TEXT, `location` TEXT, `url` TEXT, `color` INTEGER, `categories` TEXT NOT NULL DEFAULT '', `status` INTEGER NOT NULL DEFAULT 0, `percent_complete` INTEGER, `completed_at` INTEGER, `priority` INTEGER NOT NULL DEFAULT 0, `classification` INTEGER, `dtstart` INTEGER, `due` INTEGER, `duration` TEXT, `is_all_day` INTEGER NOT NULL DEFAULT 0, `timezone` TEXT, `rrule` TEXT, `rdate` TEXT, `exdate` TEXT, `recurrence_id` INTEGER, `master_id` INTEGER, `parent_id` INTEGER, `sort_order` INTEGER NOT NULL DEFAULT 0, `created_at` INTEGER, `last_modified` INTEGER, `sequence` INTEGER NOT NULL DEFAULT 0, `is_dirty` INTEGER NOT NULL DEFAULT 0, `is_deleted` INTEGER NOT NULL DEFAULT 0, `unknown_properties` TEXT, `reminders_off` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`list_id`) REFERENCES `task_lists`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`master_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`parent_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "listId", + "columnName": "list_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "href", + "columnName": "href", + "affinity": "TEXT" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER" + }, + { + "fieldPath": "categories", + "columnName": "categories", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "percentComplete", + "columnName": "percent_complete", + "affinity": "INTEGER" + }, + { + "fieldPath": "completedAt", + "columnName": "completed_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "classification", + "columnName": "classification", + "affinity": "INTEGER" + }, + { + "fieldPath": "dtstart", + "columnName": "dtstart", + "affinity": "INTEGER" + }, + { + "fieldPath": "due", + "columnName": "due", + "affinity": "INTEGER" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "TEXT" + }, + { + "fieldPath": "isAllDay", + "columnName": "is_all_day", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "timezone", + "columnName": "timezone", + "affinity": "TEXT" + }, + { + "fieldPath": "rrule", + "columnName": "rrule", + "affinity": "TEXT" + }, + { + "fieldPath": "rdate", + "columnName": "rdate", + "affinity": "TEXT" + }, + { + "fieldPath": "exdate", + "columnName": "exdate", + "affinity": "TEXT" + }, + { + "fieldPath": "recurrenceId", + "columnName": "recurrence_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "masterId", + "columnName": "master_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastModified", + "columnName": "last_modified", + "affinity": "INTEGER" + }, + { + "fieldPath": "sequence", + "columnName": "sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isDirty", + "columnName": "is_dirty", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "unknownProperties", + "columnName": "unknown_properties", + "affinity": "TEXT" + }, + { + "fieldPath": "remindersOff", + "columnName": "reminders_off", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tasks_list_id_is_deleted", + "unique": false, + "columnNames": [ + "list_id", + "is_deleted" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_list_id_is_deleted` ON `${TABLE_NAME}` (`list_id`, `is_deleted`)" + }, + { + "name": "index_tasks_parent_id", + "unique": false, + "columnNames": [ + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_parent_id` ON `${TABLE_NAME}` (`parent_id`)" + }, + { + "name": "index_tasks_master_id_recurrence_id", + "unique": false, + "columnNames": [ + "master_id", + "recurrence_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_master_id_recurrence_id` ON `${TABLE_NAME}` (`master_id`, `recurrence_id`)" + }, + { + "name": "index_tasks_is_dirty", + "unique": false, + "columnNames": [ + "is_dirty" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_is_dirty` ON `${TABLE_NAME}` (`is_dirty`)" + }, + { + "name": "index_tasks_list_id_uid_recurrence_id", + "unique": true, + "columnNames": [ + "list_id", + "uid", + "recurrence_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tasks_list_id_uid_recurrence_id` ON `${TABLE_NAME}` (`list_id`, `uid`, `recurrence_id`)" + } + ], + "foreignKeys": [ + { + "table": "task_lists", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "list_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "tasks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "master_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "tasks", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "parent_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "task_alarms", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `task_id` INTEGER NOT NULL, `minutes_before` INTEGER NOT NULL, `reference` TEXT NOT NULL DEFAULT 'DUE', `message` TEXT, `uid` TEXT NOT NULL DEFAULT '', `raw` TEXT, FOREIGN KEY(`task_id`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "taskId", + "columnName": "task_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minutesBefore", + "columnName": "minutes_before", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reference", + "columnName": "reference", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DUE'" + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT" + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "raw", + "columnName": "raw", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_task_alarms_task_id", + "unique": false, + "columnNames": [ + "task_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_task_alarms_task_id` ON `${TABLE_NAME}` (`task_id`)" + } + ], + "foreignKeys": [ + { + "table": "tasks", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "task_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '47f3bedbfbf4ddd1db32864c718de83a')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt index a6fa376..9e37f7e 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabaseMigrationTest.kt @@ -147,6 +147,21 @@ class TasksDatabaseMigrationTest { } } + @Test + fun addsAnEmptyAccountLabel() { + helper.createDatabase(TEST_DB, 3).use { db -> + db.execSQL("INSERT INTO accounts (id, display_name) VALUES (1, 'me@example.org')") + } + + helper.runMigrationsAndValidate(TEST_DB, 4, true, TasksDatabase.MIGRATION_3_4).use { db -> + db.query("SELECT display_name, label FROM accounts").use { c -> + assertThat(c.moveToFirst()).isTrue() + assertThat(c.getString(0)).isEqualTo("me@example.org") + assertThat(c.isNull(1)).isTrue() + } + } + } + private companion object { const val TEST_DB = "migration-test.db" } diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt index ac27212..cf1c0e5 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt @@ -13,6 +13,7 @@ import dagger.hilt.components.SingletonComponent import dagger.multibindings.IntoSet import de.jeanlucmakiola.agendula.data.sync.AccountCreator import de.jeanlucmakiola.agendula.data.sync.AccountRepository +import de.jeanlucmakiola.agendula.data.sync.AccountSyncRequests import de.jeanlucmakiola.agendula.data.sync.CalDavGateway import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord import de.jeanlucmakiola.agendula.data.sync.OkHttpCalDavGateway @@ -79,7 +80,7 @@ abstract class SyncBindModule { @Binds @Singleton - abstract fun bindSyncRequests(impl: SyncTrigger): SyncRequests + abstract fun bindSyncRequests(impl: AccountSyncRequests): SyncRequests @Binds @IntoSet diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountRepository.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountRepository.kt index c77a6d5..505a80f 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountRepository.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountRepository.kt @@ -97,6 +97,11 @@ class AccountRepository @Inject constructor( /** The accounts, observed, so a sync landing updates a screen that is open. */ fun observeAll(): Flow> = database.accounts().observeAll() + /** Names the account on screen; blank goes back to the default (#105). */ + suspend fun setLabel(accountId: Long, label: String) = withContext(io) { + database.accounts().setLabel(accountId, label.trim().ifEmpty { null }) + } + /** Every account's synced lists. */ fun observeSyncedLists(): Flow> = database.taskLists().observeSynced() diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountSyncRequests.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountSyncRequests.kt new file mode 100644 index 0000000..9f29c61 --- /dev/null +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/AccountSyncRequests.kt @@ -0,0 +1,20 @@ +package de.jeanlucmakiola.agendula.data.sync + +import de.jeanlucmakiola.agendula.data.di.IoDispatcher +import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import javax.inject.Inject + +/** [SyncRequests] by account id; [SyncTrigger] keys on the account's stable display name. */ +class AccountSyncRequests @Inject constructor( + private val database: TasksDatabase, + private val trigger: SyncTrigger, + @IoDispatcher private val io: CoroutineDispatcher, +) : SyncRequests { + + override suspend fun syncNow(accountId: Long) { + val name = withContext(io) { database.accounts().account(accountId)?.displayName } ?: return + trigger.enqueue(name) + } +} diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncTrigger.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncTrigger.kt index 9818c7b..fc796f2 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncTrigger.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/SyncTrigger.kt @@ -35,11 +35,7 @@ import kotlin.time.Duration.Companion.seconds @Singleton class SyncTrigger @Inject constructor( @ApplicationContext private val context: Context, -) : SyncRequests { - - override fun syncNow(accountName: String) { - enqueue(accountName) - } +) { /** * Starts a sync now. diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountDetailScreen.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountDetailScreen.kt index 1a88396..cf8e4dd 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountDetailScreen.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountDetailScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Bolt import androidx.compose.material.icons.rounded.CloudSync import androidx.compose.material.icons.rounded.DeleteOutline +import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.History import androidx.compose.material.icons.rounded.HistoryToggleOff import androidx.compose.material.icons.rounded.Inventory2 @@ -26,6 +27,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -46,9 +48,11 @@ import de.jeanlucmakiola.agendula.data.sync.DiscardedEdit import de.jeanlucmakiola.agendula.data.sync.SyncNotice import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity import de.jeanlucmakiola.agendula.ui.accounts.add.message +import de.jeanlucmakiola.agendula.ui.common.NameField import de.jeanlucmakiola.agendula.ui.common.OnResume import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.FullScreenPicker +import de.jeanlucmakiola.floret.components.GroupedHint import de.jeanlucmakiola.floret.components.GroupedListInset import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.Position @@ -90,12 +94,34 @@ internal fun AccountDetailScreen( // The confirmation sheet. The removal it starts outlives this screen, and is // shown on the accounts list rather than here. var confirming by remember { mutableStateOf(false) } + var renaming by remember { mutableStateOf(false) } + + if (renaming) { + AccountNamePicker( + current = account.label.orEmpty(), + defaultTitle = account.defaultTitle(), + onSave = { name -> + viewModel.rename(account, name) + renaming = false + }, + onDismiss = { renaming = false }, + ) + } CollapsingScaffold(title = identity.title, onBack = onBack, predictiveBack = true, visible = visible) { AccountHero(identity) Spacer(Modifier.height(24.dp)) val actions = buildList<@Composable (Position) -> Unit> { + add { position -> + GroupedRow( + title = stringResource(R.string.accounts_name), + summary = identity.title, + position = position, + leading = { Icon(Icons.Rounded.Edit, contentDescription = null) }, + onClick = { renaming = true }, + ) + } add { position -> GroupedRow( title = stringResource(R.string.accounts_last_sync), @@ -480,3 +506,30 @@ private fun errorTitle(text: String): AnnotatedString { buildAnnotatedString { withStyle(SpanStyle(color = error)) { append(text) } } } } + +/** Names an account on screen (#105); left empty, it goes back to [defaultTitle]. */ +@Composable +private fun AccountNamePicker( + current: String, + defaultTitle: String, + onSave: (String) -> Unit, + onDismiss: () -> Unit, +) { + var name by remember { mutableStateOf(current) } + FullScreenPicker( + title = stringResource(R.string.accounts_name), + onDismiss = onDismiss, + actions = { + TextButton(onClick = { onSave(name) }) { Text(stringResource(R.string.save)) } + }, + ) { + NameField( + name = name, + placeholder = defaultTitle, + autoFocus = true, + onNameChange = { name = it }, + onImeAction = { onSave(name) }, + ) + GroupedHint(stringResource(R.string.accounts_name_hint, defaultTitle)) + } +} diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountIdentity.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountIdentity.kt index 6506346..58ea126 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountIdentity.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountIdentity.kt @@ -44,6 +44,14 @@ internal data class AccountIdentity( val user: String?, ) +/** The title an account has without a [AccountEntity.label]. */ +internal fun AccountEntity.defaultTitle(): String { + val url = principalUrl?.toHttpUrlOrNull() + val provider = url?.let { CalDavProvider.forPrincipal(it) } + val host = url?.host?.removePrefix("www.") + return if (provider?.hosted == true) provider.label else host ?: displayName +} + internal fun AccountEntity.identity(): AccountIdentity { val url = principalUrl?.toHttpUrlOrNull() val provider = url?.let { CalDavProvider.forPrincipal(it) } @@ -53,7 +61,7 @@ internal fun AccountEntity.identity(): AccountIdentity { provider = provider, // The fallback is the stored name: an account with no principal URL // never got past discovery, so there is nothing better to call it. - title = if (hosted) provider.label else host ?: displayName, + title = label ?: defaultTitle(), secondary = if (hosted) host else provider?.label, user = username?.takeIf { it.isNotBlank() }, ) diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsViewModel.kt b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsViewModel.kt index 4a4e062..6ca0d60 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsViewModel.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/ui/accounts/AccountsViewModel.kt @@ -248,6 +248,10 @@ class AccountsViewModel @Inject constructor( syncTrigger.enqueue(account.displayName, expedited = true) } + fun rename(account: AccountEntity, label: String) { + viewModelScope.launch { repository.setLabel(account.id, label) } + } + /** The user has read what the last sync changed. */ fun dismissNotices(account: AccountEntity) { viewModelScope.launch { notices.dismiss(account.id) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt index 5d93f08..7190753 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/di/DataModule.kt @@ -89,7 +89,7 @@ object DataProvideModule { // backed-up `.db` does not. The backup rules carry all three files and // the app checkpoints on ON_STOP. .setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) - .addMigrations(TasksDatabase.MIGRATION_1_2, TasksDatabase.MIGRATION_2_3) + .addMigrations(TasksDatabase.MIGRATION_1_2, TasksDatabase.MIGRATION_2_3, TasksDatabase.MIGRATION_3_4) .build() /** diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncRequests.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncRequests.kt index a95da71..419e14a 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncRequests.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/SyncRequests.kt @@ -2,5 +2,5 @@ package de.jeanlucmakiola.agendula.data.sync /** Asks for an account to be synced now. A no-op in the `offline` flavor. */ fun interface SyncRequests { - fun syncNow(accountName: String) + suspend fun syncNow(accountId: Long) } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AccountDao.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AccountDao.kt index a734b9b..222f5ec 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AccountDao.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/AccountDao.kt @@ -37,6 +37,9 @@ interface AccountDao { @Query("UPDATE accounts SET last_sync_at = :at, last_sync_error = :error WHERE id = :accountId") fun recordSync(accountId: Long, at: Instant?, error: String?) + @Query("UPDATE accounts SET label = :label WHERE id = :accountId") + fun setLabel(accountId: Long, label: String?) + @Query("DELETE FROM accounts WHERE id = :accountId") fun delete(accountId: Long): Int } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt index cad74ee..d668783 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Entities.kt @@ -26,6 +26,8 @@ data class AccountEntity( @ColumnInfo(name = "username") val username: String? = null, @ColumnInfo(name = "last_sync_at") val lastSyncAt: Instant? = null, @ColumnInfo(name = "last_sync_error") val lastSyncError: String? = null, + /** A name the user gave the account (#105); [displayName] stays the stable key. */ + @ColumnInfo(name = "label") val label: String? = null, ) /** diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Projections.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Projections.kt index 74f69d4..98c321c 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Projections.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/Projections.kt @@ -11,6 +11,7 @@ import androidx.room.Embedded data class TaskListRow( @Embedded val list: TaskListEntity, @ColumnInfo(name = "account_display_name") val accountDisplayName: String?, + @ColumnInfo(name = "account_label") val accountLabel: String? = null, ) /** @@ -23,5 +24,6 @@ data class TaskRow( @ColumnInfo(name = "list_name") val listName: String, @ColumnInfo(name = "list_color") val listColor: Int, @ColumnInfo(name = "account_display_name") val accountDisplayName: String?, + @ColumnInfo(name = "account_label") val accountLabel: String? = null, @ColumnInfo(name = "list_read_only") val listReadOnly: Boolean = false, ) diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt index 5ef7233..fb67ce8 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapper.kt @@ -20,7 +20,7 @@ object RoomTaskMapper { color = row.list.color, // TaskList.accountName is non-null and the lists screen groups by it, so a // list with no account still has to report something to group under. - accountName = row.accountDisplayName ?: LocalAccount.NAME, + accountName = row.accountLabel ?: row.accountDisplayName ?: LocalAccount.NAME, accountType = if (row.list.accountId == null) LocalAccount.TYPE else CALDAV_ACCOUNT_TYPE, isSynced = row.list.isSynced, isVisible = row.list.isVisible, @@ -61,7 +61,7 @@ object RoomTaskMapper { listColor = row.listColor, taskColor = row.task.color, listName = row.listName, - accountName = row.accountDisplayName ?: LocalAccount.NAME, + accountName = row.accountLabel ?: row.accountDisplayName ?: LocalAccount.NAME, parentId = row.task.parentId, isRecurring = row.task.isRecurring, occurrenceStart = occurrenceStart, diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt index 52d28f4..747ef2d 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskDao.kt @@ -29,7 +29,7 @@ interface TaskDao { @Query( """ SELECT t.*, l.name AS list_name, l.color AS list_color, l.is_read_only AS list_read_only, - a.display_name AS account_display_name + a.display_name AS account_display_name, a.label AS account_label FROM tasks t JOIN task_lists l ON l.id = t.list_id LEFT JOIN accounts a ON a.id = l.account_id @@ -44,7 +44,7 @@ interface TaskDao { @Query( """ SELECT t.*, l.name AS list_name, l.color AS list_color, l.is_read_only AS list_read_only, - a.display_name AS account_display_name + a.display_name AS account_display_name, a.label AS account_label FROM tasks t JOIN task_lists l ON l.id = t.list_id LEFT JOIN accounts a ON a.id = l.account_id @@ -56,7 +56,7 @@ interface TaskDao { @Query( """ SELECT t.*, l.name AS list_name, l.color AS list_color, l.is_read_only AS list_read_only, - a.display_name AS account_display_name + a.display_name AS account_display_name, a.label AS account_label FROM tasks t JOIN task_lists l ON l.id = t.list_id LEFT JOIN accounts a ON a.id = l.account_id diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskListDao.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskListDao.kt index 863e507..a5d6bd8 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskListDao.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TaskListDao.kt @@ -16,7 +16,7 @@ interface TaskListDao { @Query( """ - SELECT l.*, a.display_name AS account_display_name + SELECT l.*, a.display_name AS account_display_name, a.label AS account_label FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id ORDER BY a.display_name, l.sort_order, l.name """ @@ -25,7 +25,7 @@ interface TaskListDao { @Query( """ - SELECT l.*, a.display_name AS account_display_name + SELECT l.*, a.display_name AS account_display_name, a.label AS account_label FROM task_lists l LEFT JOIN accounts a ON a.id = l.account_id WHERE l.id = :listId """ diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt index 9f9ac7e..6e24f41 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/tasks/room/TasksDatabase.kt @@ -22,7 +22,7 @@ import kotlin.time.Instant TaskEntity::class, TaskAlarmEntity::class, ], - version = 3, + version = 4, exportSchema = true, ) @TypeConverters(Converters::class) @@ -173,5 +173,12 @@ abstract class TasksDatabase : RoomDatabase() { ) } } + + /** v4 lets the user name an account (#105). */ + val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE accounts ADD COLUMN label TEXT") + } + } } } diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportViewModel.kt index 4c03174..239b770 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/imports/ImportViewModel.kt @@ -161,7 +161,7 @@ class ImportViewModel @Inject constructor( val result = importer.import(ready.parsed, target) (chosen as? ImportChoice.Existing)?.list ?.takeIf { it.accountId != null && result.imported > 0 } - ?.let { runCatching { syncRequests.syncNow(it.accountName) } } + ?.let { runCatching { syncRequests.syncNow(it.accountId!!) } } ImportStep.Done( listName = listName, imported = result.imported, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3a5dfb7..cd8a829 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -681,6 +681,8 @@ Also deletes this account\u2019s lists and tasks from this device. Never synced Last sync + Name + How this account is shown in Agendula. Leave it empty to use ā€œ%1$sā€. CalDAV account Last sync didn\u2019t finish Sync failed \u2014 the server didn\u2019t accept the password diff --git a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapperTest.kt index ea54286..83ef2a6 100644 --- a/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/agendula/data/tasks/room/RoomTaskMapperTest.kt @@ -27,4 +27,16 @@ class RoomTaskMapperTest { assertThat(RoomTaskMapper.taskList(list).acceptsWrites).isFalse() } + + @Test + fun `an account's label is shown in place of its stored name`() { + val list = TaskListRow( + list = TaskListEntity(id = 2, name = "Work", color = 0, accountId = 1), + accountDisplayName = "me@host", + accountLabel = "Work server", + ) + + assertThat(RoomTaskMapper.taskList(list).accountName).isEqualTo("Work server") + assertThat(RoomTaskMapper.taskList(list.copy(accountLabel = null)).accountName).isEqualTo("me@host") + } }