From b13c6b0e6fad4aa4b7153705b820d2dca1d741e6 Mon Sep 17 00:00:00 2001 From: Ricki Hirner Date: Tue, 30 Apr 2024 11:25:29 +0200 Subject: [PATCH] Rewrite AccountActivity to M3 (#752) * [WIP] Separate AccountScreen, use M3 elements * [WIP] Use UseCases for complex flow calculations * [WIP] Move account deletion logic into AccountRepository * Move rename operation to repository * Adapt FABs * Don't use snackbars to show when the collection list is refreshed * New collection list layout (bitfireAT/davx5#159) * [WIP] Create AccountModel from within screen * [WIP] Clean up AccountScreen * [WIP] CreateAddressBook * [WIP] Create address book / calendar screen * [WIP] Begin CollectionScreen * [WIP] CollectionScreen * Error handling * String resources * Optimizations, remove unnecessary things --- app/src/main/AndroidManifest.xml | 3 + .../at/bitfire/davdroid/db/CollectionDao.kt | 17 +- .../kotlin/at/bitfire/davdroid/db/HomeSet.kt | 7 +- .../at/bitfire/davdroid/db/HomeSetDao.kt | 8 +- .../at/bitfire/davdroid/db/PrincipalDao.kt | 3 +- .../at/bitfire/davdroid/db/ServiceDao.kt | 23 +- .../at/bitfire/davdroid/db/SyncStatsDao.kt | 5 +- .../davdroid/repository/AccountRepository.kt | 304 +++++++ .../repository/DavCollectionRepository.kt | 289 +++++++ .../repository/DavHomeSetRepository.kt | 24 + .../repository/DavServiceRepository.kt | 27 + .../repository/DavSyncStatsRepository.kt | 60 ++ .../RefreshCollectionsWorker.kt | 13 +- .../davdroid/syncadapter/AccountRepository.kt | 166 ---- .../davdroid/syncadapter/BaseSyncWorker.kt | 17 +- .../at/bitfire/davdroid/ui/AccountsModel.kt | 18 +- .../at/bitfire/davdroid/ui/AccountsScreen.kt | 27 +- .../davdroid/ui/account/AccountActivity.kt | 761 +----------------- .../davdroid/ui/account/AccountModel.kt | 651 --------------- .../davdroid/ui/account/AccountProgress.kt | 32 + .../ui/account/AccountProgressUseCase.kt | 78 ++ .../davdroid/ui/account/AccountScreen.kt | 679 ++++++++++++++++ .../davdroid/ui/account/AccountScreenModel.kt | 191 +++++ .../davdroid/ui/account/CollectionActivity.kt | 54 ++ .../ui/account/CollectionInfoDialog.kt | 150 ---- .../davdroid/ui/account/CollectionScreen.kt | 395 +++++++++ .../ui/account/CollectionScreenModel.kt | 112 +++ .../davdroid/ui/account/CollectionsList.kt | 319 ++++---- .../ui/account/CreateAddressBookActivity.kt | 232 +----- .../ui/account/CreateAddressBookModel.kt | 107 +++ .../ui/account/CreateAddressBookScreen.kt | 207 +++++ .../ui/account/CreateCalendarActivity.kt | 418 +--------- .../ui/account/CreateCalendarModel.kt | 168 ++++ .../ui/account/CreateCalendarScreen.kt | 376 +++++++++ .../ui/account/CreateCollectionComposables.kt | 69 -- .../ui/account/DeleteCollectionDialog.kt | 205 ----- .../GetBindableHomeSetsFromServiceUseCase.kt | 31 + .../GetServiceCollectionPagerUseCase.kt | 45 ++ .../davdroid/ui/account/HomeSetSelection.kt | 122 +++ .../davdroid/ui/composable/Assistant.kt | 2 - .../ExceptionInfoDialog.kt | 32 +- .../composable/SelectClientCertificateCard.kt | 1 - .../davdroid/ui/intro/IntroActivity.kt | 2 +- .../davdroid/ui/setup/AdvancedLoginModel.kt | 2 - .../davdroid/ui/setup/DetectResourcesPage.kt | 1 - .../davdroid/ui/setup/LoginDetailsPage.kt | 1 - .../davdroid/ui/setup/LoginScreenModel.kt | 2 +- .../davdroid/ui/setup/UrlLoginModel.kt | 5 - .../at/bitfire/davdroid/util/DavUtils.kt | 3 +- .../at/bitfire/davdroid/util/UrlUtils.kt | 10 + app/src/main/res/values-ca/strings.xml | 2 +- app/src/main/res/values-de/strings.xml | 2 +- app/src/main/res/values-eu/strings.xml | 2 +- app/src/main/res/values-gl/strings.xml | 2 +- app/src/main/res/values-ja/strings.xml | 2 +- app/src/main/res/values-nb-rNO/strings.xml | 2 +- app/src/main/res/values-nl/strings.xml | 2 +- app/src/main/res/values-ro/strings.xml | 2 +- app/src/main/res/values-ru/strings.xml | 2 +- app/src/main/res/values-sl-rSI/strings.xml | 2 +- app/src/main/res/values-zh/strings.xml | 2 +- app/src/main/res/values/strings.xml | 60 +- 62 files changed, 3654 insertions(+), 2902 deletions(-) create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/repository/DavCollectionRepository.kt create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/repository/DavHomeSetRepository.kt create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/repository/DavServiceRepository.kt create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/repository/DavSyncStatsRepository.kt delete mode 100644 app/src/main/kotlin/at/bitfire/davdroid/syncadapter/AccountRepository.kt delete mode 100644 app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountModel.kt create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountProgress.kt create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountProgressUseCase.kt create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountScreen.kt create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountScreenModel.kt create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionActivity.kt delete mode 100644 app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionInfoDialog.kt create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionScreen.kt create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionScreenModel.kt create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateAddressBookModel.kt create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateAddressBookScreen.kt create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCalendarModel.kt create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCalendarScreen.kt delete mode 100644 app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCollectionComposables.kt delete mode 100644 app/src/main/kotlin/at/bitfire/davdroid/ui/account/DeleteCollectionDialog.kt create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/ui/account/GetBindableHomeSetsFromServiceUseCase.kt create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/ui/account/GetServiceCollectionPagerUseCase.kt create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/ui/account/HomeSetSelection.kt rename app/src/main/kotlin/at/bitfire/davdroid/ui/{widget => composable}/ExceptionInfoDialog.kt (76%) create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/util/UrlUtils.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c7313c7d..76343e70 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -140,6 +140,9 @@ android:parentActivityName=".ui.AccountsActivity" android:exported="true"> + diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/CollectionDao.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/CollectionDao.kt index 40a9e61f..01a07b56 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/db/CollectionDao.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/CollectionDao.kt @@ -4,7 +4,6 @@ package at.bitfire.davdroid.db -import androidx.lifecycle.LiveData import androidx.paging.PagingSource import androidx.room.Dao import androidx.room.Delete @@ -13,18 +12,16 @@ import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction import androidx.room.Update +import kotlinx.coroutines.flow.Flow @Dao interface CollectionDao { - @Query("SELECT DISTINCT color FROM collection WHERE serviceId=:id") - fun colorsByServiceLive(id: Long): LiveData> - @Query("SELECT * FROM collection WHERE id=:id") fun get(id: Long): Collection? @Query("SELECT * FROM collection WHERE id=:id") - fun getLive(id: Long): LiveData + fun getFlow(id: Long): Flow @Query("SELECT * FROM collection WHERE serviceId=:serviceId") fun getByService(serviceId: Long): List @@ -35,6 +32,9 @@ interface CollectionDao { @Query("SELECT * FROM collection WHERE serviceId=:serviceId AND type=:type ORDER BY displayName COLLATE NOCASE, url COLLATE NOCASE") fun getByServiceAndType(serviceId: Long, type: String): List + @Query("SELECT COUNT(*) FROM collection WHERE serviceId=:serviceId AND type=:type") + suspend fun anyOfType(serviceId: Long, type: String): Boolean + /** * Returns collections which * - support VEVENT and/or VTODO (= supported calendar collections), or @@ -69,14 +69,17 @@ interface CollectionDao { @Insert(onConflict = OnConflictStrategy.IGNORE) fun insert(collection: Collection): Long + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertAsync(collection: Collection): Long + @Update fun update(collection: Collection) @Query("UPDATE collection SET forceReadOnly=:forceReadOnly WHERE id=:id") - fun updateForceReadOnly(id: Long, forceReadOnly: Boolean) + suspend fun updateForceReadOnly(id: Long, forceReadOnly: Boolean) @Query("UPDATE collection SET sync=:sync WHERE id=:id") - fun updateSync(id: Long, sync: Boolean) + suspend fun updateSync(id: Long, sync: Boolean) /** * Tries to insert new row, but updates existing row if already present. diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/HomeSet.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/HomeSet.kt index f7ce7900..06397b50 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/db/HomeSet.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/HomeSet.kt @@ -8,6 +8,7 @@ import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey +import at.bitfire.davdroid.util.lastSegment import okhttp3.HttpUrl @Entity(tableName = "homeset", @@ -35,4 +36,8 @@ data class HomeSet( var privBind: Boolean = true, var displayName: String? = null -) \ No newline at end of file +) { + + fun title() = displayName ?: url.lastSegment() + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/HomeSetDao.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/HomeSetDao.kt index 6d010f32..7bd550a4 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/db/HomeSetDao.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/HomeSetDao.kt @@ -4,13 +4,13 @@ package at.bitfire.davdroid.db -import androidx.lifecycle.LiveData import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.Query import androidx.room.Transaction import androidx.room.Update +import kotlinx.coroutines.flow.Flow @Dao interface HomeSetDao { @@ -24,11 +24,11 @@ interface HomeSetDao { @Query("SELECT * FROM homeset WHERE serviceId=:serviceId") fun getByService(serviceId: Long): List - @Query("SELECT * FROM homeset WHERE serviceId=:serviceId AND privBind") - fun getBindableByService(serviceId: Long): List + @Query("SELECT * FROM homeset WHERE serviceId=(SELECT id FROM service WHERE accountName=:accountName AND type=:serviceType) AND privBind ORDER BY displayName, url COLLATE NOCASE") + fun getBindableByAccountAndServiceTypeFlow(accountName: String, serviceType: String): Flow> @Query("SELECT * FROM homeset WHERE serviceId=:serviceId AND privBind") - fun getLiveBindableByService(serviceId: Long): LiveData> + fun getBindableByServiceFlow(serviceId: Long): Flow> @Insert fun insert(homeSet: HomeSet): Long diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/PrincipalDao.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/PrincipalDao.kt index c69100b1..b0a76a1b 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/db/PrincipalDao.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/PrincipalDao.kt @@ -4,7 +4,6 @@ package at.bitfire.davdroid.db -import androidx.lifecycle.LiveData import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert @@ -19,7 +18,7 @@ interface PrincipalDao { fun get(id: Long): Principal @Query("SELECT * FROM principal WHERE id=:id") - fun getLive(id: Long): LiveData + suspend fun getAsync(id: Long): Principal @Query("SELECT * FROM principal WHERE serviceId=:serviceId") fun getByService(serviceId: Long): List diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/ServiceDao.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/ServiceDao.kt index aa0c9454..89df4536 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/db/ServiceDao.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/ServiceDao.kt @@ -4,12 +4,11 @@ package at.bitfire.davdroid.db -import androidx.lifecycle.LiveData -import androidx.room.ColumnInfo import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import kotlinx.coroutines.flow.Flow @Dao interface ServiceDao { @@ -18,20 +17,11 @@ interface ServiceDao { fun getByAccountAndType(accountName: String, type: String): Service? @Query("SELECT * FROM service WHERE accountName=:accountName AND type=:type") - fun getLiveByAccountAndType(accountName: String, type: String): LiveData - - @Query("SELECT id FROM service WHERE accountName=:accountName") - fun getIdsByAccount(accountName: String): List + fun getByAccountAndTypeFlow(accountName: String, type: String): Flow @Query("SELECT id FROM service WHERE accountName=:accountName") suspend fun getIdsByAccountAsync(accountName: String): List - @Query("SELECT id FROM service WHERE accountName=:accountName AND type=:type") - fun getIdByAccountAndType(accountName: String, type: String): LiveData - - @Query("SELECT type, id FROM service WHERE accountName=:accountName") - fun getServiceTypeAndIdsByAccount(accountName: String): LiveData> - @Query("SELECT * FROM service WHERE id=:id") fun get(id: Long): Service? @@ -45,11 +35,6 @@ interface ServiceDao { fun deleteExceptAccounts(accountNames: Array) @Query("UPDATE service SET accountName=:newName WHERE accountName=:oldName") - fun renameAccount(oldName: String, newName: String) + suspend fun renameAccount(oldName: String, newName: String) -} - -data class ServiceTypeAndId( - @ColumnInfo(name = "type") val type: String, - @ColumnInfo(name = "id") val id: Long -) \ No newline at end of file +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/db/SyncStatsDao.kt b/app/src/main/kotlin/at/bitfire/davdroid/db/SyncStatsDao.kt index 82f129e8..3dbc0dfa 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/db/SyncStatsDao.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/db/SyncStatsDao.kt @@ -4,11 +4,11 @@ package at.bitfire.davdroid.db -import androidx.lifecycle.LiveData import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import kotlinx.coroutines.flow.Flow @Dao interface SyncStatsDao { @@ -17,5 +17,6 @@ interface SyncStatsDao { fun insertOrReplace(syncStats: SyncStats) @Query("SELECT * FROM syncstats WHERE collectionId=:id") - fun getLiveByCollectionId(id: Long): LiveData> + fun getByCollectionIdFlow(id: Long): Flow> + } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt b/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt new file mode 100644 index 00000000..05889bf3 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/repository/AccountRepository.kt @@ -0,0 +1,304 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.repository + +import android.Manifest +import android.accounts.Account +import android.accounts.AccountManager +import android.accounts.OnAccountsUpdateListener +import android.app.Application +import android.content.ContentResolver +import android.content.pm.PackageManager +import android.provider.CalendarContract +import android.provider.ContactsContract +import androidx.core.content.ContextCompat +import at.bitfire.davdroid.InvalidAccountException +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Credentials +import at.bitfire.davdroid.db.HomeSet +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.resource.LocalAddressBook +import at.bitfire.davdroid.resource.LocalTaskList +import at.bitfire.davdroid.servicedetection.DavResourceFinder +import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.settings.Settings +import at.bitfire.davdroid.settings.SettingsManager +import at.bitfire.davdroid.syncadapter.AccountUtils +import at.bitfire.davdroid.syncadapter.AccountsCleanupWorker +import at.bitfire.davdroid.syncadapter.BaseSyncWorker +import at.bitfire.davdroid.syncadapter.PeriodicSyncWorker +import at.bitfire.davdroid.util.TaskUtils +import at.bitfire.vcard4android.GroupMethod +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.withContext +import java.util.logging.Level +import javax.inject.Inject + +/** + * Repository for managing CalDAV/CardDAV accounts. + * + * *Note:* This class is not related to address book accounts, which are managed by + * [at.bitfire.davdroid.resource.LocalAddressBook]. + */ +class AccountRepository @Inject constructor( + val context: Application, + val db: AppDatabase, + val settingsManager: SettingsManager, + val serviceRepository: DavServiceRepository +) { + + private val accountType = context.getString(R.string.account_type) + private val accountManager = AccountManager.get(context) + + /** + * Creates a new main account with discovered services and enables periodic syncs with + * default sync interval times. + * + * @param accountName name of the account + * @param credentials server credentials + * @param config discovered server capabilities for syncable authorities + * @param groupMethod whether CardDAV contact groups are separate VCards or as contact categories + * + * @return account if account creation was successful; null otherwise (for instance because an account with this name already exists) + */ + fun create(accountName: String, credentials: Credentials?, config: DavResourceFinder.Configuration, groupMethod: GroupMethod): Account? { + val account = account(accountName) + + // create Android account + val userData = AccountSettings.initialUserData(credentials) + Logger.log.log(Level.INFO, "Creating Android account with initial config", arrayOf(account, userData)) + + if (!AccountUtils.createAccount(context, account, userData, credentials?.password)) + return null + + // add entries for account to service DB + Logger.log.log(Level.INFO, "Writing account configuration to database", config) + try { + val accountSettings = AccountSettings(context, account) + val defaultSyncInterval = settingsManager.getLong(Settings.DEFAULT_SYNC_INTERVAL) + + // Configure CardDAV service + val addrBookAuthority = context.getString(R.string.address_books_authority) + if (config.cardDAV != null) { + // insert CardDAV service + val id = insertService(accountName, Service.TYPE_CARDDAV, config.cardDAV) + + // initial CardDAV account settings + accountSettings.setGroupMethod(groupMethod) + + // start CardDAV service detection (refresh collections) + RefreshCollectionsWorker.enqueue(context, id) + + // set default sync interval and enable sync regardless of permissions + ContentResolver.setIsSyncable(account, addrBookAuthority, 1) + accountSettings.setSyncInterval(addrBookAuthority, defaultSyncInterval) + } else + ContentResolver.setIsSyncable(account, addrBookAuthority, 0) + + // Configure CalDAV service + if (config.calDAV != null) { + // insert CalDAV service + val id = insertService(accountName, Service.TYPE_CALDAV, config.calDAV) + + // start CalDAV service detection (refresh collections) + RefreshCollectionsWorker.enqueue(context, id) + + // set default sync interval and enable sync regardless of permissions + ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1) + accountSettings.setSyncInterval(CalendarContract.AUTHORITY, defaultSyncInterval) + + // if task provider present, set task sync interval and enable sync + val taskProvider = TaskUtils.currentProvider(context) + if (taskProvider != null) { + ContentResolver.setIsSyncable(account, taskProvider.authority, 1) + accountSettings.setSyncInterval(taskProvider.authority, defaultSyncInterval) + // further changes will be handled by TasksWatcher on app start or when tasks app is (un)installed + Logger.log.info("Tasks provider ${taskProvider.authority} found. Tasks sync enabled.") + } else + Logger.log.info("No tasks provider found. Did not enable tasks sync.") + } else + ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 0) + + } catch(e: InvalidAccountException) { + Logger.log.log(Level.SEVERE, "Couldn't access account settings", e) + return null + } + return account + } + + suspend fun delete(accountName: String): Boolean { + // remove account + val future = accountManager.removeAccount(account(accountName), null, null, null) + return try { + // wait for operation to complete + withContext(Dispatchers.Default) { + // blocks calling thread + future.result + } + true + } catch (e: Exception) { + Logger.log.log(Level.WARNING, "Couldn't remove account $accountName", e) + false + } + } + + fun exists(accountName: String): Boolean = + if (accountName.isEmpty()) + false + else + accountManager + .getAccountsByType(accountType) + .contains(Account(accountName, accountType)) + + fun getAll(): Array = accountManager.getAccountsByType(accountType) + + fun getAllFlow() = callbackFlow> { + val listener = OnAccountsUpdateListener { accounts -> + trySend(accounts.filter { it.type == accountType }.toSet()) + } + accountManager.addOnAccountsUpdatedListener(listener, null, true) + + awaitClose { + accountManager.removeOnAccountsUpdatedListener(listener) + } + } + + /** + * Renames an account. + * + * **Not**: It is highly advised to re-sync the account after renaming in order to restore + * a consistent state. + * + * @param oldName current name of the account + * @param newName new name the account shall be re named to + * + * @throws InvalidAccountException if the account does not exist + * @throws IllegalArgumentException if the new account name already exists + * @throws Exception (or sub-classes) on other errors + */ + suspend fun rename(oldName: String, newName: String) { + val oldAccount = account(oldName) + val newAccount = account(newName) + + // check whether new account name already exists + if (accountManager.getAccountsByType(context.getString(R.string.account_type)).contains(newAccount)) + throw IllegalArgumentException("Account with name \"$newName\" already exists") + + // remember sync intervals + val oldSettings = AccountSettings(context, oldAccount) + val authorities = mutableListOf( + context.getString(R.string.address_books_authority), + CalendarContract.AUTHORITY + ) + val tasksProvider = TaskUtils.currentProvider(context) + tasksProvider?.authority?.let { authorities.add(it) } + val syncIntervals = authorities.map { Pair(it, oldSettings.getSyncInterval(it)) } + + // rename account + try { + /* https://github.com/bitfireAT/davx5/issues/135 + Lock accounts cleanup so that the AccountsCleanupWorker doesn't run while we rename the account + because this can cause problems when: + 1. The account is renamed. + 2. The AccountsCleanupWorker is called BEFORE the services table is updated. + → AccountsCleanupWorker removes the "orphaned" services because they belong to the old account which doesn't exist anymore + 3. Now the services would be renamed, but they're not here anymore. */ + AccountsCleanupWorker.lockAccountsCleanup() + + // rename account + val future = accountManager.renameAccount(oldAccount, newName, null, null) + + // wait for operation to complete + withContext(Dispatchers.Default) { + // blocks calling thread + val newNameFromApi: Account = future.result + if (newNameFromApi.name != newName) + throw IllegalStateException("renameAccount returned ${newNameFromApi.name} instead of $newName") + } + + // account renamed, cancel maybe running synchronization of old account + BaseSyncWorker.cancelAllWork(context, oldAccount) + + // disable periodic syncs for old account + syncIntervals.forEach { (authority, _) -> + PeriodicSyncWorker.disable(context, oldAccount, authority) + } + + // update account name references in database + serviceRepository.onAccountRenamed(oldName, newName) + + // update main account of address book accounts + if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) == PackageManager.PERMISSION_GRANTED) + try { + context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.use { provider -> + for (addrBookAccount in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))) { + val addressBook = LocalAddressBook(context, addrBookAccount, provider) + if (oldAccount == addressBook.mainAccount) + addressBook.mainAccount = Account(newName, oldAccount.type) + } + } + } catch (e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't update address book accounts", e) + // Couldn't update address book accounts, but this is not a fatal error (will be fixed at next sync) + } + + // calendar provider doesn't allow changing account_name of Events + // (all events will have to be downloaded again at next sync) + + // update account_name of local tasks + try { + LocalTaskList.onRenameAccount(context, oldAccount.name, newName) + } catch (e: Exception) { + Logger.log.log(Level.WARNING, "Couldn't propagate new account name to tasks provider", e) + // Couldn't update task lists, but this is not a fatal error (will be fixed at next sync) + } + + // restore sync intervals + val newSettings = AccountSettings(context, newAccount) + for ((authority, interval) in syncIntervals) { + if (interval == null) + ContentResolver.setIsSyncable(newAccount, authority, 0) + else { + ContentResolver.setIsSyncable(newAccount, authority, 1) + newSettings.setSyncInterval(authority, interval) + } + } + } finally { + // release AccountsCleanupWorker mutex at the end of this async coroutine + AccountsCleanupWorker.unlockAccountsCleanup() + } + } + + + // helpers + + private fun account(accountName: String) = Account(accountName, accountType) + + private fun insertService(accountName: String, type: String, info: DavResourceFinder.Configuration.ServiceInfo): Long { + // insert service + val service = Service(0, accountName, type, info.principal) + val serviceId = db.serviceDao().insertOrReplace(service) + + // insert home sets + val homeSetDao = db.homeSetDao() + for (homeSet in info.homeSets) + homeSetDao.insertOrUpdateByUrl(HomeSet(0, serviceId, true, homeSet)) + + // insert collections + val collectionDao = db.collectionDao() + for (collection in info.collections.values) { + collection.serviceId = serviceId + collectionDao.insertOrUpdateByUrl(collection) + } + + return serviceId + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/repository/DavCollectionRepository.kt b/app/src/main/kotlin/at/bitfire/davdroid/repository/DavCollectionRepository.kt new file mode 100644 index 00000000..735594ef --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/repository/DavCollectionRepository.kt @@ -0,0 +1,289 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.repository + +import android.accounts.Account +import android.app.Application +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.XmlUtils +import at.bitfire.dav4jvm.property.caldav.NS_APPLE_ICAL +import at.bitfire.dav4jvm.property.caldav.NS_CALDAV +import at.bitfire.dav4jvm.property.carddav.NS_CARDDAV +import at.bitfire.dav4jvm.property.webdav.NS_WEBDAV +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.HomeSet +import at.bitfire.davdroid.network.HttpClient +import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.util.DavUtils +import at.bitfire.ical4android.util.DateUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.withContext +import okhttp3.HttpUrl +import java.io.StringWriter +import java.util.UUID +import javax.inject.Inject + +class DavCollectionRepository @Inject constructor( + val context: Application, + db: AppDatabase +) { + + private val serviceDao = db.serviceDao() + private val dao = db.collectionDao() + + suspend fun anyWebcal(serviceId: Long) = + dao.anyOfType(serviceId, Collection.TYPE_WEBCAL) + + suspend fun createAddressBook( + account: Account, + homeSet: HomeSet, + displayName: String, + description: String? + ) { + val folderName = UUID.randomUUID().toString() + val url = homeSet.url.newBuilder() + .addPathSegment(folderName) + .addPathSegment("") // trailing slash + .build() + + // create collection on server + createOnServer( + account = account, + url = url, + method = "MKCOL", + xmlBody = generateMkColXml( + addressBook = true, + displayName = displayName, + description = description + ) + ) + + // no HTTP error -> create collection locally + val collection = Collection( + serviceId = homeSet.serviceId, + homeSetId = homeSet.id, + url = url, + type = Collection.TYPE_ADDRESSBOOK, //if (addressBook) Collection.TYPE_ADDRESSBOOK else Collection.TYPE_CALENDAR, + displayName = displayName, + description = description + ) + dao.insertAsync(collection) + } + + suspend fun createCalendar( + account: Account, + homeSet: HomeSet, + color: Int?, + displayName: String, + description: String?, + timeZoneId: String?, + supportVEVENT: Boolean, + supportVTODO: Boolean, + supportVJOURNAL: Boolean + ) { + val folderName = UUID.randomUUID().toString() + val url = homeSet.url.newBuilder() + .addPathSegment(folderName) + .addPathSegment("") // trailing slash + .build() + + // create collection on server + createOnServer( + account = account, + url = url, + method = "MKCALENDAR", + xmlBody = generateMkColXml( + addressBook = false, + displayName = displayName, + description = description, + color = color, + timezoneDef = timeZoneId, + supportsVEVENT = supportVEVENT, + supportsVTODO = supportVTODO, + supportsVJOURNAL = supportVJOURNAL + ) + ) + + // no HTTP error -> create collection locally + val collection = Collection( + serviceId = homeSet.serviceId, + homeSetId = homeSet.id, + url = url, + type = Collection.TYPE_CALENDAR, + displayName = displayName, + description = description, + color = color, + timezone = timeZoneId?.let { getVTimeZone(it) }, + supportsVEVENT = supportVEVENT, + supportsVTODO = supportVTODO, + supportsVJOURNAL = supportVJOURNAL + ) + dao.insertAsync(collection) + + // Trigger service detection (because the collection may actually have other properties than the ones we have inserted). + // Some servers are known to change the supported components (VEVENT, …) after creation. + RefreshCollectionsWorker.enqueue(context, homeSet.serviceId) + } + + /** Deletes the given collection from the server and the database. */ + suspend fun delete(collection: Collection) { + val service = serviceDao.get(collection.serviceId) ?: throw IllegalArgumentException("Service not found") + val account = Account(service.accountName, context.getString(R.string.account_type)) + + HttpClient.Builder(context, AccountSettings(context, account)) + .setForeground(true) + .build().use { httpClient -> + withContext(Dispatchers.IO) { + runInterruptible { + DavResource(httpClient.okHttpClient, collection.url).delete() { + // success, otherwise an exception would have been thrown + dao.delete(collection) + } + } + } + } + } + + fun getFlow(id: Long) = dao.getFlow(id) + + suspend fun setForceReadOnly(id: Long, forceReadOnly: Boolean) { + dao.updateForceReadOnly(id, forceReadOnly) + } + + suspend fun setSync(id: Long, forceReadOnly: Boolean) { + dao.updateSync(id, forceReadOnly) + } + + + // helpers + + private suspend fun createOnServer(account: Account, url: HttpUrl, method: String, xmlBody: String) { + HttpClient.Builder(context, AccountSettings(context, account)) + .setForeground(true) + .build().use { httpClient -> + withContext(Dispatchers.IO) { + runInterruptible { + DavResource(httpClient.okHttpClient, url).mkCol( + xmlBody = xmlBody, + method = method + ) { + // success, otherwise an exception would have been thrown + } + } + } + } + } + + private fun generateMkColXml( + addressBook: Boolean, + displayName: String?, + description: String?, + color: Int? = null, + timezoneDef: String? = null, + supportsVEVENT: Boolean = true, + supportsVTODO: Boolean = true, + supportsVJOURNAL: Boolean = true + ): String { + val writer = StringWriter() + val serializer = XmlUtils.newSerializer() + serializer.apply { + setOutput(writer) + + startDocument("UTF-8", null) + setPrefix("", NS_WEBDAV) + setPrefix("CAL", NS_CALDAV) + setPrefix("CARD", NS_CARDDAV) + + if (addressBook) + startTag(NS_WEBDAV, "mkcol") + else + startTag(NS_CALDAV, "mkcalendar") + startTag(NS_WEBDAV, "set") + startTag(NS_WEBDAV, "prop") + + startTag(NS_WEBDAV, "resourcetype") + startTag(NS_WEBDAV, "collection") + endTag(NS_WEBDAV, "collection") + if (addressBook) { + startTag(NS_CARDDAV, "addressbook") + endTag(NS_CARDDAV, "addressbook") + } else { + startTag(NS_CALDAV, "calendar") + endTag(NS_CALDAV, "calendar") + } + endTag(NS_WEBDAV, "resourcetype") + + displayName?.let { + startTag(NS_WEBDAV, "displayname") + text(it) + endTag(NS_WEBDAV, "displayname") + } + + if (addressBook) { + // addressbook-specific properties + description?.let { + startTag(NS_CARDDAV, "addressbook-description") + text(it) + endTag(NS_CARDDAV, "addressbook-description") + } + + } else { + // calendar-specific properties + description?.let { + startTag(NS_CALDAV, "calendar-description") + text(it) + endTag(NS_CALDAV, "calendar-description") + } + color?.let { + startTag(NS_APPLE_ICAL, "calendar-color") + text(DavUtils.ARGBtoCalDAVColor(it)) + endTag(NS_APPLE_ICAL, "calendar-color") + } + timezoneDef?.let { + startTag(NS_CALDAV, "calendar-timezone") + cdsect(it) + endTag(NS_CALDAV, "calendar-timezone") + } + + if (!supportsVEVENT || !supportsVTODO || !supportsVJOURNAL) { + // Only if there's at least one not explicitly supported calendar component set, + // otherwise don't include the property, which means "supports everything". + if (supportsVEVENT) { + startTag(NS_CALDAV, "comp") + attribute(null, "name", "VEVENT") + endTag(NS_CALDAV, "comp") + } + if (supportsVTODO) { + startTag(NS_CALDAV, "comp") + attribute(null, "name", "VTODO") + endTag(NS_CALDAV, "comp") + } + if (supportsVJOURNAL) { + startTag(NS_CALDAV, "comp") + attribute(null, "name", "VJOURNAL") + endTag(NS_CALDAV, "comp") + } + } + } + + endTag(NS_WEBDAV, "prop") + endTag(NS_WEBDAV, "set") + if (addressBook) + endTag(NS_WEBDAV, "mkcol") + else + endTag(NS_CALDAV, "mkcalendar") + endDocument() + } + return writer.toString() + } + + private fun getVTimeZone(tzId: String): String? = + DateUtils.ical4jTimeZone(tzId)?.toString() + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/repository/DavHomeSetRepository.kt b/app/src/main/kotlin/at/bitfire/davdroid/repository/DavHomeSetRepository.kt new file mode 100644 index 00000000..0b089e6c --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/repository/DavHomeSetRepository.kt @@ -0,0 +1,24 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.repository + +import android.accounts.Account +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Service +import javax.inject.Inject + +class DavHomeSetRepository @Inject constructor( + db: AppDatabase +) { + + val dao = db.homeSetDao() + + fun getAddressBookHomeSetsFlow(account: Account) = + dao.getBindableByAccountAndServiceTypeFlow(account.name, Service.TYPE_CARDDAV) + + fun getCalendarHomeSetsFlow(account: Account) = + dao.getBindableByAccountAndServiceTypeFlow(account.name, Service.TYPE_CALDAV) + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/repository/DavServiceRepository.kt b/app/src/main/kotlin/at/bitfire/davdroid/repository/DavServiceRepository.kt new file mode 100644 index 00000000..89de08cb --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/repository/DavServiceRepository.kt @@ -0,0 +1,27 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.repository + +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Service +import javax.inject.Inject + +class DavServiceRepository @Inject constructor( + private val db: AppDatabase +) { + + private val dao = db.serviceDao() + + fun getCalDavServiceFlow(accountName: String) = + dao.getByAccountAndTypeFlow(accountName, Service.TYPE_CALDAV) + + fun getCardDavServiceFlow(accountName: String) = + dao.getByAccountAndTypeFlow(accountName, Service.TYPE_CARDDAV) + + suspend fun onAccountRenamed(oldName: String, newName: String) { + dao.renameAccount(oldName, newName) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/repository/DavSyncStatsRepository.kt b/app/src/main/kotlin/at/bitfire/davdroid/repository/DavSyncStatsRepository.kt new file mode 100644 index 00000000..dfc75508 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/repository/DavSyncStatsRepository.kt @@ -0,0 +1,60 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.repository + +import android.app.Application +import android.content.pm.PackageManager +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.log.Logger +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.text.Collator +import javax.inject.Inject + +class DavSyncStatsRepository @Inject constructor( + val context: Application, + db: AppDatabase +) { + + private val dao = db.syncStatsDao() + + data class LastSynced( + val appName: String, + val lastSynced: Long + ) + fun getLastSyncedFlow(collectionId: Long): Flow> = + dao.getByCollectionIdFlow(collectionId).map { list -> + val collator = Collator.getInstance() + list.map { stats -> + LastSynced( + appName = appNameFromAuthority(stats.authority), + lastSynced = stats.lastSync + ) + }.sortedWith { a, b -> + collator.compare(a.appName, b.appName) + } + } + + + /** + * Tries to find the application name for given authority. Returns the authority if not + * found. + * + * @param authority authority to find the application name for (ie "at.techbee.jtx") + * @return the application name of authority (ie "jtx Board") + */ + private fun appNameFromAuthority(authority: String): String { + val packageManager = context.packageManager + val packageName = packageManager.resolveContentProvider(authority, 0)?.packageName ?: authority + return try { + val appInfo = packageManager.getPackageInfo(packageName, 0).applicationInfo + packageManager.getApplicationLabel(appInfo).toString() + } catch (e: PackageManager.NameNotFoundException) { + Logger.log.warning("Application name not found for authority: $authority") + authority + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt index 87466fd4..3196e90e 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/servicedetection/RefreshCollectionsWorker.kt @@ -11,7 +11,6 @@ import android.content.Intent import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.hilt.work.HiltWorker -import androidx.lifecycle.map import androidx.work.CoroutineWorker import androidx.work.Data import androidx.work.ExistingWorkPolicy @@ -67,6 +66,7 @@ import at.bitfire.davdroid.ui.account.AccountSettingsActivity import at.bitfire.davdroid.util.DavUtils.parent import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.map import kotlinx.coroutines.runInterruptible import okhttp3.HttpUrl import okhttp3.OkHttpClient @@ -166,15 +166,16 @@ class RefreshCollectionsWorker @AssistedInject constructor( } /** - * Will tell whether a refresh worker with given service id and state exists + * Observes whether a refresh worker with given service id and state exists. * * @param workerName name of worker to find * @param workState state of worker to match - * @return boolean true if worker with matching state was found + * + * @return flow that emits `true` if worker with matching state was found (otherwise `false`) */ - fun exists(context: Context, workerName: String, workState: WorkInfo.State = WorkInfo.State.RUNNING) = - WorkManager.getInstance(context).getWorkInfosForUniqueWorkLiveData(workerName).map { - workInfoList -> workInfoList.any { workInfo -> workInfo.state == workState } + fun existsFlow(context: Context, workerName: String, workState: WorkInfo.State = WorkInfo.State.RUNNING) = + WorkManager.getInstance(context).getWorkInfosForUniqueWorkFlow(workerName).map { workInfoList -> + workInfoList.any { workInfo -> workInfo.state == workState } } } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/AccountRepository.kt b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/AccountRepository.kt deleted file mode 100644 index fb1201f4..00000000 --- a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/AccountRepository.kt +++ /dev/null @@ -1,166 +0,0 @@ -/* - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - */ - -package at.bitfire.davdroid.syncadapter - -import android.accounts.Account -import android.accounts.AccountManager -import android.accounts.OnAccountsUpdateListener -import android.app.Application -import android.content.ContentResolver -import android.provider.CalendarContract -import at.bitfire.davdroid.InvalidAccountException -import at.bitfire.davdroid.R -import at.bitfire.davdroid.db.AppDatabase -import at.bitfire.davdroid.db.Credentials -import at.bitfire.davdroid.db.HomeSet -import at.bitfire.davdroid.db.Service -import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.servicedetection.DavResourceFinder -import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker -import at.bitfire.davdroid.settings.AccountSettings -import at.bitfire.davdroid.settings.Settings -import at.bitfire.davdroid.settings.SettingsManager -import at.bitfire.davdroid.util.TaskUtils -import at.bitfire.vcard4android.GroupMethod -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.callbackFlow -import java.util.logging.Level -import javax.inject.Inject - -/** - * Repository for managing CalDAV/CardDAV accounts. - * - * *Note:* This class is not related to address book accounts, which are managed by - * [at.bitfire.davdroid.resource.LocalAddressBook]. - */ -class AccountRepository @Inject constructor( - val context: Application, - val db: AppDatabase, - val settingsManager: SettingsManager -) { - - val accountType = context.getString(R.string.account_type) - val accountManager = AccountManager.get(context) - - /** - * Creates a new main account with discovered services and enables periodic syncs with - * default sync interval times. - * - * @param accountName name of the account - * @param credentials server credentials - * @param config discovered server capabilities for syncable authorities - * @param groupMethod whether CardDAV contact groups are separate VCards or as contact categories - * - * @return account if account creation was successful; null otherwise (for instance because an account with this name already exists) - */ - fun create(accountName: String, credentials: Credentials?, config: DavResourceFinder.Configuration, groupMethod: GroupMethod): Account? { - val account = Account(accountName, context.getString(R.string.account_type)) - - // create Android account - val userData = AccountSettings.initialUserData(credentials) - Logger.log.log(Level.INFO, "Creating Android account with initial config", arrayOf(account, userData)) - - if (!AccountUtils.createAccount(context, account, userData, credentials?.password)) - return null - - // add entries for account to service DB - Logger.log.log(Level.INFO, "Writing account configuration to database", config) - try { - val accountSettings = AccountSettings(context, account) - val defaultSyncInterval = settingsManager.getLong(Settings.DEFAULT_SYNC_INTERVAL) - - // Configure CardDAV service - val addrBookAuthority = context.getString(R.string.address_books_authority) - if (config.cardDAV != null) { - // insert CardDAV service - val id = insertService(accountName, Service.TYPE_CARDDAV, config.cardDAV) - - // initial CardDAV account settings - accountSettings.setGroupMethod(groupMethod) - - // start CardDAV service detection (refresh collections) - RefreshCollectionsWorker.enqueue(context, id) - - // set default sync interval and enable sync regardless of permissions - ContentResolver.setIsSyncable(account, addrBookAuthority, 1) - accountSettings.setSyncInterval(addrBookAuthority, defaultSyncInterval) - } else - ContentResolver.setIsSyncable(account, addrBookAuthority, 0) - - // Configure CalDAV service - if (config.calDAV != null) { - // insert CalDAV service - val id = insertService(accountName, Service.TYPE_CALDAV, config.calDAV) - - // start CalDAV service detection (refresh collections) - RefreshCollectionsWorker.enqueue(context, id) - - // set default sync interval and enable sync regardless of permissions - ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1) - accountSettings.setSyncInterval(CalendarContract.AUTHORITY, defaultSyncInterval) - - // if task provider present, set task sync interval and enable sync - val taskProvider = TaskUtils.currentProvider(context) - if (taskProvider != null) { - ContentResolver.setIsSyncable(account, taskProvider.authority, 1) - accountSettings.setSyncInterval(taskProvider.authority, defaultSyncInterval) - // further changes will be handled by TasksWatcher on app start or when tasks app is (un)installed - Logger.log.info("Tasks provider ${taskProvider.authority} found. Tasks sync enabled.") - } else - Logger.log.info("No tasks provider found. Did not enable tasks sync.") - } else - ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 0) - - } catch(e: InvalidAccountException) { - Logger.log.log(Level.SEVERE, "Couldn't access account settings", e) - return null - } - return account - } - - private fun insertService(accountName: String, type: String, info: DavResourceFinder.Configuration.ServiceInfo): Long { - // insert service - val service = Service(0, accountName, type, info.principal) - val serviceId = db.serviceDao().insertOrReplace(service) - - // insert home sets - val homeSetDao = db.homeSetDao() - for (homeSet in info.homeSets) - homeSetDao.insertOrUpdateByUrl(HomeSet(0, serviceId, true, homeSet)) - - // insert collections - val collectionDao = db.collectionDao() - for (collection in info.collections.values) { - collection.serviceId = serviceId - collectionDao.insertOrUpdateByUrl(collection) - } - - return serviceId - } - - - fun exists(accountName: String): Boolean = - if (accountName.isEmpty()) - false - else - accountManager - .getAccountsByType(accountType) - .contains(Account(accountName, accountType)) - - - fun getAll() = accountManager.getAccountsByType(accountType) - - fun getAllFlow() = callbackFlow> { - val listener = OnAccountsUpdateListener { accounts -> - trySend(accounts.filter { it.type == accountType }.toSet()) - } - accountManager.addOnAccountsUpdatedListener(listener, null, true) - - awaitClose { - accountManager.removeOnAccountsUpdatedListener(listener) - } - } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/BaseSyncWorker.kt b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/BaseSyncWorker.kt index e6f960e1..29a17ccf 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/BaseSyncWorker.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/BaseSyncWorker.kt @@ -15,8 +15,6 @@ import android.provider.CalendarContract import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.getSystemService -import androidx.lifecycle.LiveData -import androidx.lifecycle.map import androidx.work.CoroutineWorker import androidx.work.Data import androidx.work.WorkInfo @@ -34,6 +32,8 @@ import at.bitfire.davdroid.ui.account.WifiPermissionsActivity import at.bitfire.davdroid.util.PermissionUtils import at.bitfire.ical4android.TaskProvider import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext import java.util.Collections @@ -84,14 +84,15 @@ abstract class BaseSyncWorker( "sync-$authority ${account.type}/${account.name}" /** - * Will tell whether >0 sync workers (both [PeriodicSyncWorker] and [OneTimeSyncWorker]) + * Observes whether >0 sync workers (both [PeriodicSyncWorker] and [OneTimeSyncWorker]) * exist, belonging to given account and authorities, and which are/is in the given worker state. * * @param workStates list of states of workers to match * @param account the account which the workers belong to * @param authorities type of sync work, ie [CalendarContract.AUTHORITY] * @param whichTag function to generate tag that should be observed for given account and authority - * @return *true* if at least one worker with matching query was found; *false* otherwise + * + * @return flow that emits `true` if at least one worker with matching query was found; `false` otherwise */ fun exists( context: Context, @@ -101,15 +102,15 @@ abstract class BaseSyncWorker( whichTag: (account: Account, authority: String) -> String = { account, authority -> commonTag(account, authority) } - ): LiveData { - val workQuery = WorkQuery.Builder - .fromStates(workStates) + ): Flow { + val workQuery = WorkQuery.Builder.fromStates(workStates) if (account != null && authorities != null) workQuery.addTags( authorities.map { authority -> whichTag(account, authority) } ) return WorkManager.getInstance(context) - .getWorkInfosLiveData(workQuery.build()).map { workInfoList -> + .getWorkInfosFlow(workQuery.build()) + .map { workInfoList -> workInfoList.isNotEmpty() } } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsModel.kt index 908c284e..07a9eea0 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsModel.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsModel.kt @@ -17,11 +17,12 @@ import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.WorkQuery import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.repository.AccountRepository import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker -import at.bitfire.davdroid.syncadapter.AccountRepository import at.bitfire.davdroid.syncadapter.BaseSyncWorker import at.bitfire.davdroid.syncadapter.OneTimeSyncWorker import at.bitfire.davdroid.syncadapter.SyncUtils +import at.bitfire.davdroid.ui.account.AccountProgress import at.bitfire.davdroid.util.broadcastReceiverFlow import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.awaitClose @@ -41,13 +42,6 @@ class AccountsModel @Inject constructor( // UI state - /** Tri-state enum to represent active / pending / idle status */ - enum class Progress { - Active, // syncing or refreshing - Pending, // sync pending - Idle - } - enum class FABStyle { WithText, Standard, @@ -56,7 +50,7 @@ class AccountsModel @Inject constructor( data class AccountInfo( val name: Account, - val progress: Progress + val progress: AccountProgress ) private val accounts = accountRepository.getAllFlow() @@ -88,15 +82,15 @@ class AccountsModel @Inject constructor( info.tags.contains(BaseSyncWorker.commonTag(account, authority)) } ) - } -> Progress.Active + } -> AccountProgress.Active workInfos.any { info -> info.state == WorkInfo.State.ENQUEUED && authorities.any { authority -> info.tags.contains(OneTimeSyncWorker.workerName(account, authority)) } - } -> Progress.Pending + } -> AccountProgress.Pending - else -> Progress.Idle + else -> AccountProgress.Idle } AccountInfo(account, progress) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsScreen.kt index 6bcd7d78..08c7d655 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsScreen.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/AccountsScreen.kt @@ -67,7 +67,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import at.bitfire.davdroid.BuildConfig import at.bitfire.davdroid.R -import at.bitfire.davdroid.ui.account.progressAlpha +import at.bitfire.davdroid.ui.account.AccountProgress import at.bitfire.davdroid.ui.composable.ActionCard import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted @@ -316,7 +316,7 @@ fun AccountsScreen_Preview_OneAccount() { accounts = listOf( AccountsModel.AccountInfo( Account("Account Name", "test"), - AccountsModel.Progress.Idle + AccountProgress.Idle ) ) ) @@ -354,23 +354,22 @@ fun AccountList( modifier = Modifier .clickable { onClickAccount(account) } .fillMaxWidth() - .padding(vertical = 4.dp) + .padding(bottom = 8.dp) ) { Column { - val progressAlpha = progressAlpha(progress) + val progressAlpha = progress.rememberAlpha() when (progress) { - AccountsModel.Progress.Active -> + AccountProgress.Active -> LinearProgressIndicator( //color = MaterialTheme.colors.onSecondary, modifier = Modifier .alpha(progressAlpha) .fillMaxWidth() ) - AccountsModel.Progress.Pending, - AccountsModel.Progress.Idle -> + AccountProgress.Pending, + AccountProgress.Idle -> LinearProgressIndicator( progress = 1f, - //color = MaterialTheme.colors.onSecondary, modifier = Modifier .alpha(progressAlpha) .fillMaxWidth() @@ -383,15 +382,15 @@ fun AccountList( contentDescription = null, modifier = Modifier .align(Alignment.CenterHorizontally) - .size(48.dp) + .size(32.dp) ) Text( text = account.name, - style = MaterialTheme.typography.headlineMedium, + style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center, modifier = Modifier - .padding(top = 8.dp) + .padding(top = 4.dp) .fillMaxWidth() ) } @@ -407,7 +406,7 @@ fun AccountList_Preview_Idle() { listOf( AccountsModel.AccountInfo( Account("Account Name", "test"), - AccountsModel.Progress.Idle + AccountProgress.Idle ) ) ) @@ -419,7 +418,7 @@ fun AccountList_Preview_SyncPending() { AccountList(listOf( AccountsModel.AccountInfo( Account("Account Name", "test"), - AccountsModel.Progress.Pending + AccountProgress.Pending ) )) } @@ -430,7 +429,7 @@ fun AccountList_Preview_Syncing() { AccountList(listOf( AccountsModel.AccountInfo( Account("Account Name", "test"), - AccountsModel.Progress.Active + AccountProgress.Active ) )) } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountActivity.kt index 8470645b..f7d679e1 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountActivity.kt @@ -4,755 +4,64 @@ package at.bitfire.davdroid.ui.account -import android.Manifest +import AccountScreen import android.accounts.Account import android.content.Intent -import android.net.Uri import android.os.Bundle -import android.widget.Toast import androidx.activity.compose.setContent -import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material.AlertDialog -import androidx.compose.material.Checkbox -import androidx.compose.material.DropdownMenu -import androidx.compose.material.DropdownMenuItem -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.FloatingActionButton -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.LinearProgressIndicator -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.SnackbarDuration -import androidx.compose.material.SnackbarHost -import androidx.compose.material.SnackbarHostState -import androidx.compose.material.Tab -import androidx.compose.material.TabRow -import androidx.compose.material.Text -import androidx.compose.material.TextButton -import androidx.compose.material.TopAppBar -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.CreateNewFolder -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.DriveFileRenameOutline -import androidx.compose.material.icons.filled.Event -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.Sync -import androidx.compose.material.icons.filled.SyncProblem -import androidx.compose.material.icons.outlined.RuleFolder -import androidx.compose.material.pullrefresh.PullRefreshIndicator -import androidx.compose.material.pullrefresh.pullRefresh -import androidx.compose.material.pullrefresh.rememberPullRefreshState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.paging.compose.LazyPagingItems -import androidx.paging.compose.collectAsLazyPagingItems -import at.bitfire.davdroid.R -import at.bitfire.davdroid.db.Collection -import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker -import at.bitfire.davdroid.settings.AccountSettings -import at.bitfire.davdroid.syncadapter.OneTimeSyncWorker -import at.bitfire.davdroid.ui.AccountsModel -import at.bitfire.davdroid.ui.M2Theme -import at.bitfire.davdroid.ui.PermissionsActivity -import at.bitfire.davdroid.ui.composable.ActionCard -import at.bitfire.davdroid.util.TaskUtils -import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.rememberMultiplePermissionsState +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch -import javax.inject.Inject +import dagger.hilt.android.components.ActivityComponent @AndroidEntryPoint class AccountActivity : AppCompatActivity() { + @EntryPoint + @InstallIn(ActivityComponent::class) + interface AccountScreenEntryPoint { + fun accountModelAssistedFactory(): AccountScreenModel.Factory + } + companion object { const val EXTRA_ACCOUNT = "account" } - @Inject - lateinit var modelFactory: AccountModel.Factory - val model by viewModels { - val account = intent.getParcelableExtra(EXTRA_ACCOUNT) as? Account - ?: throw IllegalArgumentException("AccountActivity requires EXTRA_ACCOUNT") - object: ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class) = - modelFactory.create(account) as T - } - } - - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - model.invalid.observe(this) { invalid -> - if (invalid) - // account does not exist anymore - finish() - } - model.renameAccountError.observe(this) { error -> - if (error != null) { - Toast.makeText(this, error, Toast.LENGTH_LONG).show() - model.renameAccountError.value = null - } - } + val account = intent.getParcelableExtra(EXTRA_ACCOUNT) as? Account + ?: throw IllegalArgumentException("AccountActivity requires EXTRA_ACCOUNT") setContent { - M2Theme { - val cardDavSvc by model.cardDavSvc.observeAsState() - val canCreateAddressBook by model.canCreateAddressBook.observeAsState(false) - val cardDavRefreshing by model.cardDavRefreshing.observeAsState(false) - val cardDavSyncPending by model.cardDavSyncPending.observeAsState(false) - val cardDavSyncing by model.cardDavSyncing.observeAsState(false) - val cardDavProgress: AccountsModel.Progress = when { - cardDavRefreshing || cardDavSyncing -> AccountsModel.Progress.Active - cardDavSyncPending -> AccountsModel.Progress.Pending - else -> AccountsModel.Progress.Idle - } - val addressBooks by model.addressBooksPager.observeAsState() - - val calDavSvc by model.calDavSvc.observeAsState() - val canCreateCalendar by model.canCreateCalendar.observeAsState(false) - val calDavRefreshing by model.calDavRefreshing.observeAsState(false) - val calDavSyncPending by model.calDavSyncPending.observeAsState(false) - val calDavSyncing by model.calDavSyncing.observeAsState(false) - val calDavProgress: AccountsModel.Progress = when { - calDavRefreshing || calDavSyncing -> AccountsModel.Progress.Active - calDavSyncPending -> AccountsModel.Progress.Pending - else -> AccountsModel.Progress.Idle - } - val calendars by model.calendarsPager.observeAsState() - val subscriptions by model.webcalPager.observeAsState() - - var installIcsx5 by remember { mutableStateOf(false) } - - AccountOverview( - account = model.account, - showOnlyPersonal = - model.showOnlyPersonal.observeAsState( - AccountSettings.ShowOnlyPersonal(onlyPersonal = false, locked = true) - ).value, - onSetShowOnlyPersonal = { - model.setShowOnlyPersonal(it) - }, - hasCardDav = cardDavSvc != null, - canCreateAddressBook = canCreateAddressBook, - cardDavProgress = cardDavProgress, - cardDavRefreshing = cardDavRefreshing, - addressBooks = addressBooks?.flow?.collectAsLazyPagingItems(), - hasCalDav = calDavSvc != null, - canCreateCalendar = canCreateCalendar, - calDavProgress = calDavProgress, - calDavRefreshing = calDavRefreshing, - calendars = calendars?.flow?.collectAsLazyPagingItems(), - subscriptions = subscriptions?.flow?.collectAsLazyPagingItems(), - onUpdateCollectionSync = { collectionId, sync -> - model.setCollectionSync(collectionId, sync) - }, - onChangeForceReadOnly = { id, forceReadOnly -> - model.setCollectionForceReadOnly(id, forceReadOnly) - }, - onSubscribe = { item -> - installIcsx5 = !subscribeWebcal(item) - }, - installIcsx5 = installIcsx5, - onRefreshCollections = { - cardDavSvc?.let { svc -> - RefreshCollectionsWorker.enqueue(this@AccountActivity, svc.id) - } - calDavSvc?.let { svc -> - RefreshCollectionsWorker.enqueue(this@AccountActivity, svc.id) - } - }, - onSync = { - OneTimeSyncWorker.enqueueAllAuthorities(this, model.account, manual = true) - }, - onAccountSettings = { - val intent = Intent(this, AccountSettingsActivity::class.java) - intent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, model.account) - startActivity(intent, null) - }, - onRenameAccount = { newName -> - model.renameAccount(newName) - }, - onDeleteAccount = { - model.deleteAccount() - }, - onNavigateUp = ::onSupportNavigateUp - ) - } - } - } - - /** - * Subscribes to a Webcal using a compatible app like ICSx5. - * - * @return true if a compatible Webcal app is installed, false otherwise - */ - private fun subscribeWebcal(item: Collection): Boolean { - // subscribe - var uri = Uri.parse(item.source.toString()) - when { - uri.scheme.equals("http", true) -> uri = uri.buildUpon().scheme("webcal").build() - uri.scheme.equals("https", true) -> uri = uri.buildUpon().scheme("webcals").build() - } - - val intent = Intent(Intent.ACTION_VIEW, uri) - item.displayName?.let { intent.putExtra("title", it) } - item.color?.let { intent.putExtra("color", it) } - - if (packageManager.resolveActivity(intent, 0) != null) { - startActivity(intent) - return true - } - - return false - } - -} - - -@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class) -@Composable -fun AccountOverview( - account: Account, - showOnlyPersonal: AccountSettings.ShowOnlyPersonal, - onSetShowOnlyPersonal: (showOnlyPersonal: Boolean) -> Unit, - hasCardDav: Boolean, - canCreateAddressBook: Boolean, - cardDavProgress: AccountsModel.Progress, - cardDavRefreshing: Boolean, - addressBooks: LazyPagingItems?, - hasCalDav: Boolean, - canCreateCalendar: Boolean, - calDavProgress: AccountsModel.Progress, - calDavRefreshing: Boolean, - calendars: LazyPagingItems?, - subscriptions: LazyPagingItems?, - onUpdateCollectionSync: (collectionId: Long, sync: Boolean) -> Unit = { _, _ -> }, - onChangeForceReadOnly: (collectionId: Long, forceReadOnly: Boolean) -> Unit = { _, _ -> }, - onSubscribe: (Collection) -> Unit = {}, - installIcsx5: Boolean = false, - onRefreshCollections: () -> Unit = {}, - onSync: () -> Unit = {}, - onAccountSettings: () -> Unit = {}, - onRenameAccount: (newName: String) -> Unit = {}, - onDeleteAccount: () -> Unit = {}, - onNavigateUp: () -> Unit = {} -) { - val context = LocalContext.current - - val pullRefreshing by remember { mutableStateOf(false) } - val pullRefreshState = rememberPullRefreshState( - pullRefreshing, - onRefresh = onRefreshCollections - ) - - // tabs calculation - var nextIdx = -1 - @Suppress("KotlinConstantConditions") - val idxCardDav: Int? = if (hasCardDav) ++nextIdx else null - val idxCalDav: Int? = if (hasCalDav) ++nextIdx else null - val idxWebcal: Int? = if ((subscriptions?.itemCount ?: 0) > 0) ++nextIdx else null - val nrPages = - (if (idxCardDav != null) 1 else 0) + - (if (idxCalDav != null) 1 else 0) + - (if (idxWebcal != null) 1 else 0) - val pagerState = rememberPagerState(pageCount = { nrPages }) - - // snackbar - val snackbarHostState = remember { SnackbarHostState() } - AccountOverview_SnackbarContent( - snackbarHostState = snackbarHostState, - currentPageIsCardDav = pagerState.currentPage == idxCardDav, - cardDavRefreshing = cardDavRefreshing, - calDavRefreshing = calDavRefreshing - ) - - Scaffold( - topBar = { - TopAppBar( - navigationIcon = { - IconButton(onClick = onNavigateUp) { - Icon(Icons.AutoMirrored.Default.ArrowBack, stringResource(R.string.navigate_up)) - } + AccountScreen( + account = account, + onAccountSettings = { + val intent = Intent(this, AccountSettingsActivity::class.java) + intent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account) + startActivity(intent, null) }, - title = { - Text( - account.name, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + onCreateAddressBook = { + val intent = Intent(this, CreateAddressBookActivity::class.java) + intent.putExtra(CreateAddressBookActivity.EXTRA_ACCOUNT, account) + startActivity(intent) }, - actions = { - AccountOverview_Actions( - account = account, - canCreateAddressBook = canCreateAddressBook, - canCreateCalendar = canCreateCalendar, - showOnlyPersonal = showOnlyPersonal, - onSetShowOnlyPersonal = onSetShowOnlyPersonal, - currentPage = pagerState.currentPage, - idxCardDav = idxCardDav, - idxCalDav = idxCalDav, - onRenameAccount = onRenameAccount, - onDeleteAccount = onDeleteAccount, - onAccountSettings = onAccountSettings - ) - } + onCreateCalendar = { + val intent = Intent(this, CreateCalendarActivity::class.java) + intent.putExtra(CreateCalendarActivity.EXTRA_ACCOUNT, account) + startActivity(intent) + }, + onCollectionDetails = { collection -> + val intent = Intent(this, CollectionActivity::class.java) + intent.putExtra(CollectionActivity.EXTRA_ACCOUNT, account) + intent.putExtra(CollectionActivity.EXTRA_COLLECTION_ID, collection.id) + startActivity(intent, null) + }, + onNavUp = ::onSupportNavigateUp, + onFinish = ::finish ) - }, - floatingActionButton = { - Column { - FloatingActionButton( - onClick = onRefreshCollections, - backgroundColor = MaterialTheme.colors.background, - modifier = Modifier.padding(bottom = 16.dp) - ) { - // Material 3: add Tooltip - Icon(Icons.Outlined.RuleFolder, stringResource(R.string.account_refresh_collections)) - } - - if (pagerState.currentPage == idxCardDav || pagerState.currentPage == idxCalDav) - FloatingActionButton(onClick = onSync) { - // Material 3: add Tooltip - Icon(Icons.Default.Sync, stringResource(R.string.account_synchronize_now)) - } - } - }, - snackbarHost = { - SnackbarHost(snackbarHostState) - }, - modifier = Modifier.pullRefresh(pullRefreshState) - ) { padding -> - Column { - if (nrPages > 0) { - val scope = rememberCoroutineScope() - - TabRow( - selectedTabIndex = pagerState.currentPage, - modifier = Modifier.padding(padding) - ) { - if (idxCardDav != null) - Tab( - selected = pagerState.currentPage == idxCardDav, - onClick = { - scope.launch { - pagerState.scrollToPage(idxCardDav) - } - } - ) { - Text( - stringResource(R.string.account_carddav).uppercase(), - modifier = Modifier.padding(8.dp) - ) - } - - if (idxCalDav != null) { - Tab( - selected = pagerState.currentPage == idxCalDav, - onClick = { - scope.launch { - pagerState.scrollToPage(idxCalDav) - } - } - ) { - Text( - stringResource(R.string.account_caldav).uppercase(), - modifier = Modifier.padding(8.dp) - ) - } - } - - if (idxWebcal != null) { - Tab( - selected = pagerState.currentPage == idxWebcal, - onClick = { - scope.launch { - pagerState.scrollToPage(idxWebcal) - } - } - ) { - Text( - stringResource(R.string.account_webcal).uppercase(), - modifier = Modifier.padding(8.dp) - ) - } - } - } - - HorizontalPager( - pagerState, - verticalAlignment = Alignment.Top, - modifier = Modifier - .fillMaxWidth() - .weight(1f) - ) { index -> - Box { - when (index) { - idxCardDav -> - ServiceTab( - requiredPermissions = listOf(Manifest.permission.WRITE_CONTACTS), - progress = cardDavProgress, - collections = addressBooks, - onUpdateCollectionSync = onUpdateCollectionSync, - onChangeForceReadOnly = onChangeForceReadOnly - ) - - idxCalDav -> { - val permissions = mutableListOf(Manifest.permission.WRITE_CALENDAR) - TaskUtils.currentProvider(context)?.let { tasksProvider -> - permissions += tasksProvider.permissions - } - ServiceTab( - requiredPermissions = permissions, - progress = calDavProgress, - collections = calendars, - onUpdateCollectionSync = onUpdateCollectionSync, - onChangeForceReadOnly = onChangeForceReadOnly - ) - } - - idxWebcal -> { - Column { - if (installIcsx5) - ActionCard( - icon = Icons.Default.Event, - actionText = stringResource(R.string.account_install_icsx5), - onAction = { - val installIntent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=at.bitfire.icsdroid")) - if (context.packageManager.resolveActivity(installIntent, 0) != null) - context.startActivity(installIntent) - }, - modifier = Modifier.padding(top = 8.dp) - ) { - Text(stringResource(R.string.account_no_webcal_handler_found)) - } - else - Text( - stringResource(R.string.account_webcal_external_app), - style = MaterialTheme.typography.body2, - modifier = Modifier.padding(top = 8.dp, start = 8.dp, end = 8.dp) - ) - - ServiceTab( - requiredPermissions = listOf(Manifest.permission.WRITE_CALENDAR), - progress = calDavProgress, - collections = subscriptions, - onSubscribe = onSubscribe - ) - } - } - } - - PullRefreshIndicator( - refreshing = pullRefreshing, - state = pullRefreshState, - modifier = Modifier.align(Alignment.TopCenter) - ) - } - } - } - } - } -} - -@Preview -@Composable -fun AccountOverview_CardDAV_CalDAV() { - AccountOverview( - account = Account("test@example.com", "test"), - showOnlyPersonal = AccountSettings.ShowOnlyPersonal(false, true), - onSetShowOnlyPersonal = {}, - hasCardDav = true, - canCreateAddressBook = false, - cardDavProgress = AccountsModel.Progress.Active, - cardDavRefreshing = false, - addressBooks = null, - hasCalDav = true, - canCreateCalendar = true, - calDavProgress = AccountsModel.Progress.Pending, - calDavRefreshing = false, - calendars = null, - subscriptions = null - ) -} - -@Composable -fun AccountOverview_Actions( - account: Account, - canCreateAddressBook: Boolean, - canCreateCalendar: Boolean, - showOnlyPersonal: AccountSettings.ShowOnlyPersonal, - onSetShowOnlyPersonal: (showOnlyPersonal: Boolean) -> Unit, - currentPage: Int, - idxCardDav: Int?, - idxCalDav: Int?, - onRenameAccount: (newName: String) -> Unit, - onDeleteAccount: () -> Unit, - onAccountSettings: () -> Unit -) { - val context = LocalContext.current - - var showDeleteAccountDialog by remember { mutableStateOf(false) } - var showRenameAccountDialog by remember { mutableStateOf(false) } - - var overflowOpen by remember { mutableStateOf(false) } - IconButton(onClick = onAccountSettings) { - Icon(Icons.Default.Settings, stringResource(R.string.account_settings)) - } - IconButton(onClick = { overflowOpen = !overflowOpen }) { - Icon(Icons.Default.MoreVert, stringResource(R.string.options_menu)) - } - DropdownMenu( - expanded = overflowOpen, - onDismissRequest = { overflowOpen = false } - ) { - // TAB-SPECIFIC ACTIONS - - // create collection - if (currentPage == idxCardDav && canCreateAddressBook) { - // create address book - DropdownMenuItem(onClick = { - val intent = Intent(context, CreateAddressBookActivity::class.java) - intent.putExtra(CreateAddressBookActivity.EXTRA_ACCOUNT, account) - context.startActivity(intent) - - overflowOpen = false - }) { - Icon( - Icons.Default.CreateNewFolder, - contentDescription = stringResource(R.string.create_addressbook), - modifier = Modifier.padding(end = 8.dp) - ) - Text(stringResource(R.string.create_addressbook)) - } - } else if (currentPage == idxCalDav && canCreateCalendar) { - // create calendar - DropdownMenuItem(onClick = { - val intent = Intent(context, CreateCalendarActivity::class.java) - intent.putExtra(CreateCalendarActivity.EXTRA_ACCOUNT, account) - context.startActivity(intent) - - overflowOpen = false - }) { - Icon( - Icons.Default.CreateNewFolder, - contentDescription = stringResource(R.string.create_calendar), - modifier = Modifier.padding(end = 8.dp) - ) - Text(stringResource(R.string.create_calendar)) - } - } - - // GENERAL ACTIONS - - // show only personal - DropdownMenuItem( - onClick = { - onSetShowOnlyPersonal(!showOnlyPersonal.onlyPersonal) - overflowOpen = false - }, - enabled = !showOnlyPersonal.locked - ) { - Text(stringResource(R.string.account_only_personal)) - Checkbox( - checked = showOnlyPersonal.onlyPersonal, - enabled = !showOnlyPersonal.locked, - onCheckedChange = { - onSetShowOnlyPersonal(it) - overflowOpen = false - } - ) - } - - // rename account - DropdownMenuItem(onClick = { - showRenameAccountDialog = true - overflowOpen = false - }) { - Icon( - Icons.Default.DriveFileRenameOutline, - contentDescription = stringResource(R.string.account_rename), - modifier = Modifier.padding(end = 8.dp) - ) - Text(stringResource(R.string.account_rename)) - } - - // delete account - DropdownMenuItem(onClick = { - showDeleteAccountDialog = true - overflowOpen = false - }) { - Icon( - Icons.Default.Delete, - contentDescription = stringResource(R.string.account_delete), - modifier = Modifier.padding(end = 8.dp) - ) - Text(stringResource(R.string.account_delete)) } } - // modal dialogs - if (showRenameAccountDialog) - RenameAccountDialog( - oldName = account.name, - onRenameAccount = { newName -> - onRenameAccount(newName) - showRenameAccountDialog = false - }, - onDismiss = { showRenameAccountDialog = false } - ) - if (showDeleteAccountDialog) - DeleteAccountDialog( - onConfirm = onDeleteAccount, - onDismiss = { showDeleteAccountDialog = false } - ) -} - -@Composable -fun AccountOverview_SnackbarContent( - snackbarHostState: SnackbarHostState, - currentPageIsCardDav: Boolean, - cardDavRefreshing: Boolean, - calDavRefreshing: Boolean -) { - val context = LocalContext.current - - // show snackbar when refreshing collection list - val currentTabRefreshing = - if (currentPageIsCardDav) - cardDavRefreshing - else - calDavRefreshing - LaunchedEffect(currentTabRefreshing) { - if (currentTabRefreshing) - snackbarHostState.showSnackbar( - context.getString(R.string.account_refreshing_collections), - duration = SnackbarDuration.Indefinite - ) - } -} - -@Composable -@Preview -fun DeleteAccountDialog( - onConfirm: () -> Unit = {}, - onDismiss: () -> Unit = {} -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.account_delete_confirmation_title)) }, - text = { Text(stringResource(R.string.account_delete_confirmation_text)) }, - confirmButton = { - TextButton(onClick = onConfirm) { - Text(stringResource(android.R.string.ok).uppercase()) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(android.R.string.cancel).uppercase()) - } - } - ) -} - -@OptIn(ExperimentalPermissionsApi::class) -@Composable -fun ServiceTab( - requiredPermissions: List, - progress: AccountsModel.Progress, - collections: LazyPagingItems?, - onUpdateCollectionSync: (collectionId: Long, sync: Boolean) -> Unit = { _, _ -> }, - onChangeForceReadOnly: (collectionId: Long, forceReadOnly: Boolean) -> Unit = { _, _ -> }, - onSubscribe: (Collection) -> Unit = {}, -) { - val context = LocalContext.current - - Column { - // progress indicator - val progressAlpha = progressAlpha(progress) - when (progress) { - AccountsModel.Progress.Active -> LinearProgressIndicator( - color = MaterialTheme.colors.secondary, - modifier = Modifier - .alpha(progressAlpha) - .fillMaxWidth() - ) - AccountsModel.Progress.Pending, - AccountsModel.Progress.Idle -> LinearProgressIndicator( - color = MaterialTheme.colors.secondary, - progress = 1f, - modifier = Modifier - .alpha(progressAlpha) - .fillMaxWidth() - ) - } - - // permissions warning - val permissionsState = rememberMultiplePermissionsState(requiredPermissions) - if (!permissionsState.allPermissionsGranted) - ActionCard( - icon = Icons.Default.SyncProblem, - actionText = stringResource(R.string.account_manage_permissions), - onAction = { - val intent = Intent(context, PermissionsActivity::class.java) - context.startActivity(intent) - } - ) { - Text(stringResource(R.string.account_missing_permissions)) - } - - // collection list - if (collections != null) - CollectionsList( - collections, - onChangeSync = onUpdateCollectionSync, - onChangeForceReadOnly = onChangeForceReadOnly, - onSubscribe = onSubscribe, - modifier = Modifier.weight(1f) - ) - } -} - -@Composable -fun progressAlpha(progress: AccountsModel.Progress): Float { - val progressAlpha by animateFloatAsState( - when (progress) { - AccountsModel.Progress.Active -> 1f - AccountsModel.Progress.Pending -> 0.5f - AccountsModel.Progress.Idle -> 0f - }, - label = "progressAlpha", - animationSpec = tween(500) - ) - return progressAlpha } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountModel.kt deleted file mode 100644 index 1e3a62d4..00000000 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountModel.kt +++ /dev/null @@ -1,651 +0,0 @@ -/* - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - */ - -package at.bitfire.davdroid.ui.account - -import android.Manifest -import android.accounts.Account -import android.accounts.AccountManager -import android.accounts.OnAccountsUpdateListener -import android.annotation.SuppressLint -import android.app.Application -import android.content.ContentResolver -import android.content.pm.PackageManager -import android.os.Handler -import android.os.Looper -import android.provider.CalendarContract -import android.provider.ContactsContract -import androidx.annotation.MainThread -import androidx.annotation.WorkerThread -import androidx.core.content.ContextCompat -import androidx.lifecycle.LiveData -import androidx.lifecycle.MediatorLiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.asLiveData -import androidx.lifecycle.map -import androidx.lifecycle.switchMap -import androidx.lifecycle.viewModelScope -import androidx.paging.Pager -import androidx.paging.PagingConfig -import androidx.work.WorkInfo -import at.bitfire.dav4jvm.DavResource -import at.bitfire.dav4jvm.XmlUtils -import at.bitfire.dav4jvm.property.caldav.NS_APPLE_ICAL -import at.bitfire.dav4jvm.property.caldav.NS_CALDAV -import at.bitfire.dav4jvm.property.carddav.NS_CARDDAV -import at.bitfire.dav4jvm.property.webdav.NS_WEBDAV -import at.bitfire.davdroid.InvalidAccountException -import at.bitfire.davdroid.R -import at.bitfire.davdroid.db.AppDatabase -import at.bitfire.davdroid.db.Collection -import at.bitfire.davdroid.db.HomeSet -import at.bitfire.davdroid.db.Service -import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.network.HttpClient -import at.bitfire.davdroid.resource.LocalAddressBook -import at.bitfire.davdroid.resource.LocalTaskList -import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker -import at.bitfire.davdroid.settings.AccountSettings -import at.bitfire.davdroid.syncadapter.AccountsCleanupWorker -import at.bitfire.davdroid.syncadapter.BaseSyncWorker -import at.bitfire.davdroid.syncadapter.OneTimeSyncWorker -import at.bitfire.davdroid.syncadapter.PeriodicSyncWorker -import at.bitfire.davdroid.util.DavUtils -import at.bitfire.davdroid.util.TaskUtils -import at.bitfire.ical4android.util.DateUtils -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.launch -import net.fortuna.ical4j.model.Calendar -import java.io.StringWriter -import java.util.Optional -import java.util.logging.Level - -class AccountModel @AssistedInject constructor( - val context: Application, - val db: AppDatabase, - @Assisted val account: Account -): ViewModel(), OnAccountsUpdateListener { - - @AssistedFactory - interface Factory { - fun create(account: Account): AccountModel - } - - companion object { - const val PAGER_SIZE = 20 - } - - /** whether the account is invalid and the AccountActivity shall be closed */ - val invalid = MutableLiveData() - - private val settings = AccountSettings(context, account) - private val refreshSettingsSignal = MutableLiveData(Unit) - val showOnlyPersonal = refreshSettingsSignal.switchMap { - object : LiveData() { - init { - viewModelScope.launch(Dispatchers.IO) { - postValue(settings.getShowOnlyPersonal()) - } - } - } - } - fun setShowOnlyPersonal(showOnlyPersonal: Boolean) = viewModelScope.launch(Dispatchers.IO) { - settings.setShowOnlyPersonal(showOnlyPersonal) - refreshSettingsSignal.postValue(Unit) - } - - val accountManager: AccountManager = AccountManager.get(context) - - val cardDavSvc = db.serviceDao().getLiveByAccountAndType(account.name, Service.TYPE_CARDDAV) - val bindableAddressBookHomesets = cardDavSvc.switchMap { svc -> - if (svc != null) - db.homeSetDao().getLiveBindableByService(svc.id) - else - MutableLiveData(emptyList()) - } - val canCreateAddressBook = bindableAddressBookHomesets.map { homeSets -> - homeSets.isNotEmpty() - } - val cardDavRefreshing = cardDavSvc.switchMap { svc -> - if (svc == null) - return@switchMap null - RefreshCollectionsWorker.exists(context, RefreshCollectionsWorker.workerName(svc.id)) - } - val cardDavSyncPending = BaseSyncWorker.exists( - context, - listOf(WorkInfo.State.ENQUEUED), - account, - listOf(context.getString(R.string.address_books_authority)), - whichTag = { account, authority -> - // we are only interested in pending OneTimeSyncWorkers because there's always a pending PeriodicSyncWorker - OneTimeSyncWorker.workerName(account, authority) - } - ) - val cardDavSyncing = BaseSyncWorker.exists( - context, - listOf(WorkInfo.State.RUNNING), - account, - listOf(context.getString(R.string.address_books_authority)) - ) - val addressBooksPager = CollectionPager(db, cardDavSvc, Collection.TYPE_ADDRESSBOOK, showOnlyPersonal) - - private val tasksProvider = TaskUtils.currentProviderFlow(context, viewModelScope) - val calDavSvc = db.serviceDao().getLiveByAccountAndType(account.name, Service.TYPE_CALDAV) - val bindableCalendarHomesets = calDavSvc.switchMap { svc -> - if (svc != null) - db.homeSetDao().getLiveBindableByService(svc.id) - else - MutableLiveData(emptyList()) - } - val canCreateCalendar = bindableCalendarHomesets.map { homeSets -> - homeSets.isNotEmpty() - } - val calDavRefreshing = calDavSvc.switchMap { svc -> - if (svc == null) - return@switchMap null - RefreshCollectionsWorker.exists(context, RefreshCollectionsWorker.workerName(svc.id)) - } - val calDavSyncPending = tasksProvider.asLiveData().switchMap { tasks -> - BaseSyncWorker.exists( - context, - listOf(WorkInfo.State.ENQUEUED), - account, - listOfNotNull(CalendarContract.AUTHORITY, tasks?.authority), - whichTag = { account, authority -> - // we are only interested in pending OneTimeSyncWorkers because there's always a pending PeriodicSyncWorker - OneTimeSyncWorker.workerName(account, authority) - } - ) - } - val calDavSyncing = tasksProvider.asLiveData().switchMap { tasks -> - BaseSyncWorker.exists( - context, - listOf(WorkInfo.State.RUNNING), - account, - listOfNotNull(CalendarContract.AUTHORITY, tasks?.authority) - ) - } - val calendarsPager = CollectionPager(db, calDavSvc, Collection.TYPE_CALENDAR, showOnlyPersonal) - val webcalPager = CollectionPager(db, calDavSvc, Collection.TYPE_WEBCAL, showOnlyPersonal) - - val renameAccountError = MutableLiveData() - - - init { - accountManager.addOnAccountsUpdatedListener(this, null, true) - } - - override fun onCleared() { - super.onCleared() - accountManager.removeOnAccountsUpdatedListener(this) - } - - override fun onAccountsUpdated(accounts: Array) { - if (!accounts.contains(account)) - invalid.postValue(true) - } - - - // actions - - /** - * Will try to rename the [account] to given name. - * - * @param newName new account name - */ - fun renameAccount(newName: String) { - val oldAccount = account - - // remember sync intervals - val oldSettings = try { - AccountSettings(context, oldAccount) - } catch (e: InvalidAccountException) { - renameAccountError.postValue(context.getString(R.string.account_invalid)) - invalid.postValue(true) - return - } - - val authorities = mutableListOf( - context.getString(R.string.address_books_authority), - CalendarContract.AUTHORITY - ) - tasksProvider.value?.authority?.let { authorities.add(it) } - val syncIntervals = authorities.map { Pair(it, oldSettings.getSyncInterval(it)) } - - val accountManager = AccountManager.get(context) - // check whether name is already taken - if (accountManager.getAccountsByType(context.getString(R.string.account_type)).map { it.name }.contains(newName)) { - Logger.log.log(Level.WARNING, "Account with name \"$newName\" already exists") - renameAccountError.postValue(context.getString(R.string.account_rename_exists_already)) - return - } - - try { - /* https://github.com/bitfireAT/davx5/issues/135 - Lock accounts cleanup so that the AccountsCleanupWorker doesn't run while we rename the account - because this can cause problems when: - 1. The account is renamed. - 2. The AccountsCleanupWorker is called BEFORE the services table is updated. - → AccountsCleanupWorker removes the "orphaned" services because they belong to the old account which doesn't exist anymore - 3. Now the services would be renamed, but they're not here anymore. */ - AccountsCleanupWorker.lockAccountsCleanup() - - // Renaming account - accountManager.renameAccount(oldAccount, newName, @MainThread { - if (it.result?.name == newName /* account has new name -> success */) - viewModelScope.launch(Dispatchers.Default + NonCancellable) { - try { - onAccountRenamed(accountManager, oldAccount, newName, syncIntervals) - } finally { - // release AccountsCleanupWorker mutex at the end of this async coroutine - AccountsCleanupWorker.unlockAccountsCleanup() - } - } else - // release AccountsCleanupWorker mutex now - AccountsCleanupWorker.unlockAccountsCleanup() - - // close AccountActivity with old name - invalid.postValue(true) - }, null) - } catch (e: Exception) { - Logger.log.log(Level.WARNING, "Couldn't rename account", e) - renameAccountError.postValue(context.getString(R.string.account_rename_couldnt_rename)) - } - } - - /** - * Called when an account has been renamed. - * - * @param oldAccount the old account - * @param newName the new account - * @param syncIntervals map with entries of type (authority -> sync interval) of the old account - */ - @SuppressLint("Recycle") - @WorkerThread - fun onAccountRenamed(accountManager: AccountManager, oldAccount: Account, newName: String, syncIntervals: List>) { - // account has now been renamed - Logger.log.info("Updating account name references") - - // disable periodic workers of old account - syncIntervals.forEach { (authority, _) -> - PeriodicSyncWorker.disable(context, oldAccount, authority) - } - - // cancel maybe running synchronization - BaseSyncWorker.cancelAllWork(context, oldAccount) - /*for (addrBookAccount in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))) - SyncWorker.cancelSync(context, addrBookAccount)*/ - - // update account name references in database - try { - db.serviceDao().renameAccount(oldAccount.name, newName) - } catch (e: Exception) { - Logger.log.log(Level.SEVERE, "Couldn't update service DB", e) - renameAccountError.postValue(context.getString(R.string.account_rename_couldnt_rename)) - return - } - - // update main account of address book accounts - if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) == PackageManager.PERMISSION_GRANTED) - try { - context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.use { provider -> - for (addrBookAccount in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))) { - val addressBook = LocalAddressBook(context, addrBookAccount, provider) - if (oldAccount == addressBook.mainAccount) - addressBook.mainAccount = Account(newName, oldAccount.type) - } - } - } catch (e: Exception) { - Logger.log.log(Level.SEVERE, "Couldn't update address book accounts", e) - } - - // calendar provider doesn't allow changing account_name of Events - // (all events will have to be downloaded again) - - // update account_name of local tasks - try { - LocalTaskList.onRenameAccount(context, oldAccount.name, newName) - } catch (e: Exception) { - Logger.log.log(Level.SEVERE, "Couldn't propagate new account name to tasks provider", e) - } - - // retain sync intervals - val newAccount = Account(newName, oldAccount.type) - val newSettings = AccountSettings(context, newAccount) - for ((authority, interval) in syncIntervals) { - if (interval == null) - ContentResolver.setIsSyncable(newAccount, authority, 0) - else { - ContentResolver.setIsSyncable(newAccount, authority, 1) - newSettings.setSyncInterval(authority, interval) - } - } - - // synchronize again - OneTimeSyncWorker.enqueueAllAuthorities(context, newAccount, manual = true) - } - - - /** Deletes the account from the system (won't touch collections on the server). */ - fun deleteAccount() { - val accountManager = AccountManager.get(context) - accountManager.removeAccount(account, null, { future -> - try { - if (future.result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT)) - Handler(Looper.getMainLooper()).post { - invalid.postValue(true) - } - } catch(e: Exception) { - Logger.log.log(Level.SEVERE, "Couldn't remove account", e) - } - }, null) - } - - - val createCollectionResult = MutableLiveData>() - /** - * Creates a WebDAV collection using MKCOL or MKCALENDAR. - * - * @param homeSet home set into which the collection shall be created - * @param addressBook *true* if an address book shall be created, *false* if a calendar should be created - * @param name name (path segment) of the collection - */ - fun createCollection( - homeSet: HomeSet, - addressBook: Boolean, - name: String, - displayName: String?, - description: String?, - color: Int? = null, - timeZoneId: String? = null, - supportsVEVENT: Boolean? = null, - supportsVTODO: Boolean? = null, - supportsVJOURNAL: Boolean? = null - ) = viewModelScope.launch(Dispatchers.IO) { - HttpClient.Builder(context, AccountSettings(context, account)) - .setForeground(true) - .build().use { httpClient -> - try { - // delete on server - val url = homeSet.url.newBuilder() - .addPathSegment(name) - .addPathSegment("") // trailing slash - .build() - val dav = DavResource(httpClient.okHttpClient, url) - - val xml = generateMkColXml( - addressBook = addressBook, - displayName = displayName, - description = description, - color = color, - timezoneDef = timeZoneId?.let { tzId -> - DateUtils.ical4jTimeZone(tzId)?.let { tz -> - val cal = Calendar() - cal.components += tz.vTimeZone - cal.toString() - } - }, - supportsVEVENT = supportsVEVENT, - supportsVTODO = supportsVTODO, - supportsVJOURNAL = supportsVJOURNAL - ) - - dav.mkCol( - xmlBody = xml, - method = if (addressBook) "MKCOL" else "MKCALENDAR" - ) { - // success, otherwise an exception would have been thrown - } - - // no HTTP error -> create collection locally - val collection = Collection( - serviceId = homeSet.serviceId, - homeSetId = homeSet.id, - url = url, - type = if (addressBook) Collection.TYPE_ADDRESSBOOK else Collection.TYPE_CALENDAR, - displayName = displayName, - description = description - ) - db.collectionDao().insert(collection) - - // trigger service detection (because the collection may actually have other properties than the ones we have inserted) - RefreshCollectionsWorker.enqueue(context, homeSet.serviceId) - - // post success - createCollectionResult.postValue(Optional.empty()) - } catch (e: Exception) { - Logger.log.log(Level.SEVERE, "Couldn't create collection", e) - // post error - createCollectionResult.postValue(Optional.of(e)) - } - } - } - - private fun generateMkColXml( - addressBook: Boolean, - displayName: String?, - description: String?, - color: Int? = null, - timezoneDef: String? = null, - supportsVEVENT: Boolean? = null, - supportsVTODO: Boolean? = null, - supportsVJOURNAL: Boolean? = null - ): String { - val writer = StringWriter() - val serializer = XmlUtils.newSerializer() - serializer.apply { - setOutput(writer) - - startDocument("UTF-8", null) - setPrefix("", NS_WEBDAV) - setPrefix("CAL", NS_CALDAV) - setPrefix("CARD", NS_CARDDAV) - - if (addressBook) - startTag(NS_WEBDAV, "mkcol") - else - startTag(NS_CALDAV, "mkcalendar") - startTag(NS_WEBDAV, "set") - startTag(NS_WEBDAV, "prop") - - startTag(NS_WEBDAV, "resourcetype") - startTag(NS_WEBDAV, "collection") - endTag(NS_WEBDAV, "collection") - if (addressBook) { - startTag(NS_CARDDAV, "addressbook") - endTag(NS_CARDDAV, "addressbook") - } else { - startTag(NS_CALDAV, "calendar") - endTag(NS_CALDAV, "calendar") - } - endTag(NS_WEBDAV, "resourcetype") - - displayName?.let { - startTag(NS_WEBDAV, "displayname") - text(it) - endTag(NS_WEBDAV, "displayname") - } - - if (addressBook) { - // addressbook-specific properties - description?.let { - startTag(NS_CARDDAV, "addressbook-description") - text(it) - endTag(NS_CARDDAV, "addressbook-description") - } - - } else { - // calendar-specific properties - description?.let { - startTag(NS_CALDAV, "calendar-description") - text(it) - endTag(NS_CALDAV, "calendar-description") - } - color?.let { - startTag(NS_APPLE_ICAL, "calendar-color") - text(DavUtils.ARGBtoCalDAVColor(it)) - endTag(NS_APPLE_ICAL, "calendar-color") - } - timezoneDef?.let { - startTag(NS_CALDAV, "calendar-timezone") - cdsect(it) - endTag(NS_CALDAV, "calendar-timezone") - } - - if (supportsVEVENT != null || supportsVTODO != null || supportsVJOURNAL != null) { - // only if there's at least one explicitly supported calendar component set, otherwise don't include the property - if (supportsVEVENT != false) { - startTag(NS_CALDAV, "comp") - attribute(null, "name", "VEVENT") - endTag(NS_CALDAV, "comp") - } - if (supportsVTODO != false) { - startTag(NS_CALDAV, "comp") - attribute(null, "name", "VTODO") - endTag(NS_CALDAV, "comp") - } - if (supportsVJOURNAL != false) { - startTag(NS_CALDAV, "comp") - attribute(null, "name", "VJOURNAL") - endTag(NS_CALDAV, "comp") - } - } - } - - endTag(NS_WEBDAV, "prop") - endTag(NS_WEBDAV, "set") - if (addressBook) - endTag(NS_WEBDAV, "mkcol") - else - endTag(NS_CALDAV, "mkcalendar") - endDocument() - } - return writer.toString() - } - - val deleteCollectionResult = MutableLiveData>() - /** Deletes the given collection from the database and the server. */ - fun deleteCollection(collection: Collection) = viewModelScope.launch(Dispatchers.IO) { - HttpClient.Builder(context, AccountSettings(context, account)) - .setForeground(true) - .build().use { httpClient -> - try { - // delete on server - val davResource = DavResource(httpClient.okHttpClient, collection.url) - davResource.delete(null) {} - - // delete in database - db.collectionDao().delete(collection) - - // post success - deleteCollectionResult.postValue(Optional.empty()) - } catch (e: Exception) { - Logger.log.log(Level.SEVERE, "Couldn't delete collection", e) - // post error - deleteCollectionResult.postValue(Optional.of(e)) - } - } - } - - fun setCollectionSync(id: Long, sync: Boolean) = viewModelScope.launch(Dispatchers.IO) { - db.collectionDao().updateSync(id, sync) - } - - fun setCollectionForceReadOnly(id: Long, forceReadOnly: Boolean) = viewModelScope.launch(Dispatchers.IO) { - db.collectionDao().updateForceReadOnly(id, forceReadOnly) - } - - - // helpers - - fun getCollectionOwner(collection: Collection): LiveData { - val id = collection.ownerId ?: return MutableLiveData(null) - return db.principalDao().getLive(id).map { principal -> - if (principal == null) - return@map null - principal.displayName ?: principal.url.toString() - } - } - - fun getCollectionLastSynced(collection: Collection): LiveData> { - return db.syncStatsDao().getLiveByCollectionId(collection.id).map { syncStatsList -> - val syncStatsMap = syncStatsList.associateBy { it.authority } - val interestingAuthorities = listOfNotNull( - ContactsContract.AUTHORITY, - CalendarContract.AUTHORITY, - TaskUtils.currentProvider(context)?.authority - ) - val result = mutableMapOf() - for (authority in interestingAuthorities) { - val lastSync = syncStatsMap[authority]?.lastSync - if (lastSync != null) - result[getAppNameFromAuthority(authority)] = lastSync - } - result - } - } - - /** - * Tries to find the application name for given authority. Returns the authority if not - * found. - * - * @param authority authority to find the application name for (ie "at.techbee.jtx") - * @return the application name of authority (ie "jtx Board") - */ - private fun getAppNameFromAuthority(authority: String): String { - val packageManager = context.packageManager - val packageName = packageManager.resolveContentProvider(authority, 0)?.packageName ?: authority - return try { - val appInfo = packageManager.getPackageInfo(packageName, 0).applicationInfo - packageManager.getApplicationLabel(appInfo).toString() - } catch (e: PackageManager.NameNotFoundException) { - Logger.log.warning("Application name not found for authority: $authority") - authority - } - } - - - class CollectionPager( - val db: AppDatabase, - service: LiveData, - private val collectionType: String, - showOnlyPersonal: LiveData - ) : MediatorLiveData?>() { - - var _serviceId: Long? = null - var _onlyPersonal: Boolean? = null - - init { - addSource(service) { - _serviceId = it?.id - calculate() - } - addSource(showOnlyPersonal) { - _onlyPersonal = it.onlyPersonal - calculate() - } - } - - fun calculate() { - val serviceId = _serviceId ?: return - val onlyPersonal = _onlyPersonal ?: return - value = Pager( - config = PagingConfig(PAGER_SIZE), - pagingSourceFactory = { - if (onlyPersonal) - db.collectionDao().pagePersonalByServiceAndType(serviceId, collectionType) - else - db.collectionDao().pageByServiceAndType(serviceId, collectionType) - } - ) - } - - } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountProgress.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountProgress.kt new file mode 100644 index 00000000..504383e7 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountProgress.kt @@ -0,0 +1,32 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue + +/** Tri-state enum to represent active / pending / idle status */ +enum class AccountProgress { + Active, // syncing or refreshing + Pending, // sync pending + Idle; // idle + + @Composable + fun rememberAlpha(): Float { + val progressAlpha by animateFloatAsState( + when (this@AccountProgress) { + Active -> 1f + Pending -> 0.5f + Idle -> 0f + }, + label = "progressAlpha", + animationSpec = tween(500) + ) + return progressAlpha + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountProgressUseCase.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountProgressUseCase.kt new file mode 100644 index 00000000..8c3f0114 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountProgressUseCase.kt @@ -0,0 +1,78 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import android.accounts.Account +import android.app.Application +import androidx.work.WorkInfo +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker +import at.bitfire.davdroid.syncadapter.BaseSyncWorker +import at.bitfire.davdroid.syncadapter.OneTimeSyncWorker +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import javax.inject.Inject + +class AccountProgressUseCase @Inject constructor( + val context: Application +) { + + operator fun invoke( + account: Account, + serviceFlow: Flow, + authoritiesFlow: Flow> + ): Flow { + val serviceRefreshing = isServiceRefreshing(serviceFlow) + val syncPending = isSyncPending(account, authoritiesFlow) + val syncRunning = isSyncRunning(account, authoritiesFlow) + + return combine(serviceRefreshing, syncPending, syncRunning) { refreshing, pending, syncing -> + when { + refreshing || syncing -> AccountProgress.Active + pending -> AccountProgress.Pending + else -> AccountProgress.Idle + } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + fun isServiceRefreshing(serviceFlow: Flow): Flow = + serviceFlow.flatMapLatest { service -> + if (service == null) + flowOf(false) + else + RefreshCollectionsWorker.existsFlow(context, RefreshCollectionsWorker.workerName(service.id)) + } + + @OptIn(ExperimentalCoroutinesApi::class) + fun isSyncPending(account: Account, authoritiesFlow: Flow>): Flow = + authoritiesFlow.flatMapLatest { authorities -> + BaseSyncWorker.exists( + context = context, + workStates = listOf(WorkInfo.State.ENQUEUED), + account = account, + authorities = authorities, + whichTag = { _, authority -> + // we are only interested in pending OneTimeSyncWorkers because there's always a pending PeriodicSyncWorker + OneTimeSyncWorker.workerName(account, authority) + } + ) + } + + @OptIn(ExperimentalCoroutinesApi::class) + fun isSyncRunning(account: Account, authoritiesFlow: Flow>): Flow = + authoritiesFlow.flatMapLatest { authorities -> + BaseSyncWorker.exists( + context = context, + workStates = listOf(WorkInfo.State.RUNNING), + account = account, + authorities = authorities + ) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountScreen.kt new file mode 100644 index 00000000..0fb58a81 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountScreen.kt @@ -0,0 +1,679 @@ + +import android.Manifest +import android.accounts.Account +import android.app.Activity +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CreateNewFolder +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.DriveFileRenameOutline +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material.icons.filled.SyncProblem +import androidx.compose.material.icons.outlined.RuleFolder +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshContainer +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.ui.AppTheme +import at.bitfire.davdroid.ui.PermissionsActivity +import at.bitfire.davdroid.ui.account.AccountActivity +import at.bitfire.davdroid.ui.account.AccountProgress +import at.bitfire.davdroid.ui.account.AccountScreenModel +import at.bitfire.davdroid.ui.account.CollectionsList +import at.bitfire.davdroid.ui.account.RenameAccountDialog +import at.bitfire.davdroid.ui.composable.ActionCard +import at.bitfire.davdroid.util.TaskUtils +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.rememberMultiplePermissionsState +import dagger.hilt.android.EntryPointAccessors +import kotlinx.coroutines.launch + +@Composable +fun AccountScreen( + account: Account, + onAccountSettings: () -> Unit, + onCreateAddressBook: () -> Unit, + onCreateCalendar: () -> Unit, + onCollectionDetails: (Collection) -> Unit, + onNavUp: () -> Unit, + onFinish: () -> Unit +) { + val context = LocalContext.current as Activity + val entryPoint = EntryPointAccessors.fromActivity(context, AccountActivity.AccountScreenEntryPoint::class.java) + val model = viewModel( + factory = AccountScreenModel.factoryFromAccount(entryPoint.accountModelAssistedFactory(), account) + ) + + val addressBooksPager by model.addressBooksPager.collectAsState(null) + val calendarsPager by model.calendarsPager.collectAsState(null) + val subscriptionsPager by model.webcalPager.collectAsState(null) + + AccountScreen( + accountName = account.name, + error = model.error, + onResetError = model::resetError, + invalidAccount = model.invalidAccount.collectAsStateWithLifecycle(false).value, + showOnlyPersonal = model.showOnlyPersonal.collectAsStateWithLifecycle( + initialValue = AccountSettings.ShowOnlyPersonal(onlyPersonal = false, locked = false) + ).value, + onSetShowOnlyPersonal = model::setShowOnlyPersonal, + hasCardDav = model.hasCardDav.collectAsStateWithLifecycle(false).value, + canCreateAddressBook = model.canCreateAddressBook.collectAsStateWithLifecycle(false).value, + cardDavProgress = model.cardDavProgress.collectAsStateWithLifecycle(AccountProgress.Idle).value, + addressBooks = addressBooksPager?.flow?.collectAsLazyPagingItems(), + hasCalDav = model.hasCalDav.collectAsStateWithLifecycle(initialValue = false).value, + canCreateCalendar = model.canCreateCalendar.collectAsStateWithLifecycle(false).value, + calDavProgress = model.calDavProgress.collectAsStateWithLifecycle(AccountProgress.Idle).value, + calendars = calendarsPager?.flow?.collectAsLazyPagingItems(), + hasWebcal = model.hasWebcal.collectAsStateWithLifecycle(false).value, + subscriptions = subscriptionsPager?.flow?.collectAsLazyPagingItems(), + onUpdateCollectionSync = model::setCollectionSync, + onSubscribe = { collection -> + // subscribe + var uri = Uri.parse(collection.source.toString()) + when { + uri.scheme.equals("http", true) -> uri = uri.buildUpon().scheme("webcal").build() + uri.scheme.equals("https", true) -> uri = uri.buildUpon().scheme("webcals").build() + } + + val intent = Intent(Intent.ACTION_VIEW, uri) + collection.displayName?.let { intent.putExtra("title", it) } + collection.color?.let { intent.putExtra("color", it) } + + if (context.packageManager.resolveActivity(intent, 0) != null) + context.startActivity(intent) + else + model.noWebcalApp() + }, + onCollectionDetails = onCollectionDetails, + showNoWebcalApp = model.showNoWebcalApp, + resetShowNoWebcalApp = model::resetShowNoWebcalApp, + onRefreshCollections = model::refreshCollections, + onSync = model::sync, + onAccountSettings = onAccountSettings, + onCreateAddressBook = onCreateAddressBook, + onCreateCalendar = onCreateCalendar, + onRenameAccount = model::renameAccount, + onDeleteAccount = model::deleteAccount, + onNavUp = onNavUp, + onFinish = onFinish + ) +} + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun AccountScreen( + accountName: String, + error: String? = null, + onResetError: () -> Unit = {}, + invalidAccount: Boolean = false, + showOnlyPersonal: AccountSettings.ShowOnlyPersonal, + onSetShowOnlyPersonal: (showOnlyPersonal: Boolean) -> Unit = {}, + hasCardDav: Boolean, + canCreateAddressBook: Boolean, + cardDavProgress: AccountProgress, + addressBooks: LazyPagingItems?, + hasCalDav: Boolean, + canCreateCalendar: Boolean, + calDavProgress: AccountProgress, + calendars: LazyPagingItems?, + hasWebcal: Boolean, + subscriptions: LazyPagingItems?, + onUpdateCollectionSync: (collectionId: Long, sync: Boolean) -> Unit = { _, _ -> }, + onSubscribe: (Collection) -> Unit = {}, + onCollectionDetails: (Collection) -> Unit = {}, + showNoWebcalApp: Boolean = false, + resetShowNoWebcalApp: () -> Unit = {}, + onRefreshCollections: () -> Unit = {}, + onSync: () -> Unit = {}, + onAccountSettings: () -> Unit = {}, + onCreateAddressBook: () -> Unit = {}, + onCreateCalendar: () -> Unit = {}, + onRenameAccount: (newName: String) -> Unit = {}, + onDeleteAccount: () -> Unit = {}, + onNavUp: () -> Unit = {}, + onFinish: () -> Unit = {} +) { + AppTheme { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + if (invalidAccount) + onFinish() + + val pullRefreshState = rememberPullToRefreshState() + LaunchedEffect(pullRefreshState.isRefreshing) { + if (pullRefreshState.isRefreshing) { + onSync() + pullRefreshState.endRefresh() + } + } + + val snackbarHostState = remember { SnackbarHostState() } + LaunchedEffect(error) { + if (error != null) + scope.launch { + snackbarHostState.showSnackbar(error) + onResetError() + } + } + + // tabs calculation + var nextIdx = -1 + + @Suppress("KotlinConstantConditions") + val idxCardDav: Int? = if (hasCardDav) ++nextIdx else null + val idxCalDav: Int? = if (hasCalDav) ++nextIdx else null + val idxWebcal: Int? = if (hasWebcal) ++nextIdx else null + val nrPages = + (if (idxCardDav != null) 1 else 0) + + (if (idxCalDav != null) 1 else 0) + + (if (idxWebcal != null) 1 else 0) + val pagerState = rememberPagerState(pageCount = { nrPages }) + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = onNavUp) { + Icon(Icons.AutoMirrored.Default.ArrowBack, stringResource(R.string.navigate_up)) + } + }, + title = { + Text( + text = accountName, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + actions = { + AccountScreen_Actions( + accountName = accountName, + canCreateAddressBook = canCreateAddressBook, + onCreateAddressBook = onCreateAddressBook, + canCreateCalendar = canCreateCalendar, + onCreateCalendar = onCreateCalendar, + showOnlyPersonal = showOnlyPersonal, + onSetShowOnlyPersonal = onSetShowOnlyPersonal, + currentPage = pagerState.currentPage, + idxCardDav = idxCardDav, + idxCalDav = idxCalDav, + onRenameAccount = onRenameAccount, + onDeleteAccount = onDeleteAccount, + onAccountSettings = onAccountSettings + ) + } + ) + }, + floatingActionButton = { + Column(horizontalAlignment = Alignment.End) { + ExtendedFloatingActionButton( + text = { + Text(stringResource(R.string.account_refresh_collections)) + }, + icon = { + Icon(Icons.Outlined.RuleFolder, stringResource(R.string.account_refresh_collections)) + }, + onClick = onRefreshCollections, + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(bottom = 16.dp) + ) + + if (pagerState.currentPage == idxCardDav || pagerState.currentPage == idxCalDav) + ExtendedFloatingActionButton( + text = { + Text(stringResource(R.string.account_synchronize_now)) + }, + icon = { + Icon(Icons.Default.Sync, stringResource(R.string.account_synchronize_now)) + }, + onClick = onSync + ) + } + }, + snackbarHost = { + SnackbarHost(snackbarHostState) + } + ) { padding -> + Box( + Modifier + .padding(padding) + .nestedScroll(pullRefreshState.nestedScrollConnection) + ) { + Column { + if (nrPages > 0) { + TabRow(selectedTabIndex = pagerState.currentPage) { + if (idxCardDav != null) + Tab( + selected = pagerState.currentPage == idxCardDav, + onClick = { + scope.launch { + pagerState.scrollToPage(idxCardDav) + } + } + ) { + Text( + stringResource(R.string.account_carddav), + modifier = Modifier.padding(8.dp) + ) + } + + if (idxCalDav != null) { + Tab( + selected = pagerState.currentPage == idxCalDav, + onClick = { + scope.launch { + pagerState.scrollToPage(idxCalDav) + } + } + ) { + Text( + stringResource(R.string.account_caldav), + modifier = Modifier.padding(8.dp) + ) + } + } + + if (idxWebcal != null) { + Tab( + selected = pagerState.currentPage == idxWebcal, + onClick = { + scope.launch { + pagerState.scrollToPage(idxWebcal) + } + } + ) { + Text( + stringResource(R.string.account_webcal), + modifier = Modifier.padding(8.dp) + ) + } + } + } + + HorizontalPager( + pagerState, + verticalAlignment = Alignment.Top, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { index -> + when (index) { + idxCardDav -> + AccountScreen_ServiceTab( + requiredPermissions = listOf(Manifest.permission.WRITE_CONTACTS), + progress = cardDavProgress, + collections = addressBooks, + onUpdateCollectionSync = onUpdateCollectionSync, + onCollectionDetails = onCollectionDetails + ) + + idxCalDav -> { + val permissions = mutableListOf(Manifest.permission.WRITE_CALENDAR) + TaskUtils.currentProvider(context)?.let { tasksProvider -> + permissions += tasksProvider.permissions + } + AccountScreen_ServiceTab( + requiredPermissions = permissions, + progress = calDavProgress, + collections = calendars, + onUpdateCollectionSync = onUpdateCollectionSync, + onCollectionDetails = onCollectionDetails + ) + } + + idxWebcal -> { + LaunchedEffect(showNoWebcalApp) { + if (showNoWebcalApp) { + if (snackbarHostState.showSnackbar( + message = context.getString(R.string.account_no_webcal_handler_found), + actionLabel = context.getString(R.string.account_install_icsx5), + duration = SnackbarDuration.Long + ) == SnackbarResult.ActionPerformed + ) { + val installIntent = Intent( + Intent.ACTION_VIEW, + Uri.parse("market://details?id=at.bitfire.icsdroid") + ) + if (context.packageManager.resolveActivity(installIntent, 0) != null) + context.startActivity(installIntent) + } + resetShowNoWebcalApp() + } + } + + Column { + Text( + stringResource(R.string.account_webcal_external_app), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 8.dp, start = 8.dp, end = 8.dp) + ) + + AccountScreen_ServiceTab( + requiredPermissions = listOf(Manifest.permission.WRITE_CALENDAR), + progress = calDavProgress, + collections = subscriptions, + onSubscribe = onSubscribe + ) + } + } + } + } + } + } + + PullToRefreshContainer( + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter) + ) + } + } + } +} + +@Composable +fun AccountScreen_Actions( + accountName: String, + canCreateAddressBook: Boolean, + onCreateAddressBook: () -> Unit, + canCreateCalendar: Boolean, + onCreateCalendar: () -> Unit, + showOnlyPersonal: AccountSettings.ShowOnlyPersonal, + onSetShowOnlyPersonal: (showOnlyPersonal: Boolean) -> Unit, + currentPage: Int, + idxCardDav: Int?, + idxCalDav: Int?, + onRenameAccount: (newName: String) -> Unit, + onDeleteAccount: () -> Unit, + onAccountSettings: () -> Unit +) { + val context = LocalContext.current + + var showDeleteAccountDialog by remember { mutableStateOf(false) } + var showRenameAccountDialog by remember { mutableStateOf(false) } + + var overflowOpen by remember { mutableStateOf(false) } + IconButton(onClick = onAccountSettings) { + Icon(Icons.Default.Settings, stringResource(R.string.account_settings)) + } + IconButton(onClick = { overflowOpen = !overflowOpen }) { + Icon(Icons.Default.MoreVert, stringResource(R.string.options_menu)) + } + DropdownMenu( + expanded = overflowOpen, + onDismissRequest = { overflowOpen = false } + ) { + // TAB-SPECIFIC ACTIONS + + // create collection + if (currentPage == idxCardDav && canCreateAddressBook) { + // create address book + DropdownMenuItem( + leadingIcon = { + Icon( + Icons.Default.CreateNewFolder, + contentDescription = stringResource(R.string.create_addressbook), + modifier = Modifier.padding(end = 8.dp) + ) + }, + text = { + Text(stringResource(R.string.create_addressbook)) + }, + onClick = { + onCreateAddressBook() + overflowOpen = false + } + ) + } else if (currentPage == idxCalDav && canCreateCalendar) { + // create calendar + DropdownMenuItem( + leadingIcon = { + Icon( + Icons.Default.CreateNewFolder, + contentDescription = stringResource(R.string.create_calendar), + modifier = Modifier.padding(end = 8.dp) + ) + }, + text = { + Text(stringResource(R.string.create_calendar)) + }, + onClick = { + onCreateCalendar() + overflowOpen = false + } + ) + } + + // GENERAL ACTIONS + + // show only personal + DropdownMenuItem( + leadingIcon = { + Checkbox( + checked = showOnlyPersonal.onlyPersonal, + enabled = !showOnlyPersonal.locked, + onCheckedChange = { + onSetShowOnlyPersonal(it) + overflowOpen = false + } + ) + }, + text = { + Text(stringResource(R.string.account_only_personal)) + }, + onClick = { + onSetShowOnlyPersonal(!showOnlyPersonal.onlyPersonal) + overflowOpen = false + }, + enabled = !showOnlyPersonal.locked + ) + + // rename account + DropdownMenuItem( + leadingIcon = { + Icon( + Icons.Default.DriveFileRenameOutline, + contentDescription = stringResource(R.string.account_rename), + modifier = Modifier.padding(end = 8.dp) + ) + }, + text = { + Text(stringResource(R.string.account_rename)) + }, + onClick = { + showRenameAccountDialog = true + overflowOpen = false + } + ) + + // delete account + DropdownMenuItem( + leadingIcon = { + Icon( + Icons.Default.Delete, + contentDescription = stringResource(R.string.account_delete), + modifier = Modifier.padding(end = 8.dp) + ) + }, + text = { + Text(stringResource(R.string.account_delete)) + }, + onClick = { + showDeleteAccountDialog = true + overflowOpen = false + } + ) + } + + // modal dialogs + if (showRenameAccountDialog) + RenameAccountDialog( + oldName = accountName, + onRenameAccount = { newName -> + onRenameAccount(newName) + showRenameAccountDialog = false + }, + onDismiss = { showRenameAccountDialog = false } + ) + if (showDeleteAccountDialog) + DeleteAccountDialog( + onConfirm = onDeleteAccount, + onDismiss = { showDeleteAccountDialog = false } + ) +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun AccountScreen_ServiceTab( + requiredPermissions: List, + progress: AccountProgress, + collections: LazyPagingItems?, + onUpdateCollectionSync: (collectionId: Long, sync: Boolean) -> Unit = { _, _ -> }, + onSubscribe: (Collection) -> Unit = {}, + onCollectionDetails: ((Collection) -> Unit)? = null +) { + val context = LocalContext.current + + Column { + // progress indicator + val progressAlpha = progress.rememberAlpha() + when (progress) { + AccountProgress.Active -> LinearProgressIndicator( + modifier = Modifier + .alpha(progressAlpha) + .fillMaxWidth() + ) + AccountProgress.Pending, + AccountProgress.Idle -> LinearProgressIndicator( + progress = { 1f }, + modifier = Modifier + .alpha(progressAlpha) + .fillMaxWidth() + ) + } + + // permissions warning + if (!LocalInspectionMode.current) { + val permissionsState = rememberMultiplePermissionsState(requiredPermissions) + if (!permissionsState.allPermissionsGranted) + ActionCard( + icon = Icons.Default.SyncProblem, + actionText = stringResource(R.string.account_manage_permissions), + onAction = { + val intent = Intent(context, PermissionsActivity::class.java) + context.startActivity(intent) + }, + modifier = Modifier.padding(8.dp) + ) { + Text(stringResource(R.string.account_missing_permissions)) + } + + // collection list + if (collections != null) + CollectionsList( + collections, + onChangeSync = onUpdateCollectionSync, + onSubscribe = onSubscribe, + onCollectionDetails = onCollectionDetails, + modifier = Modifier.weight(1f) + ) + } + } +} + +@Preview +@Composable +fun AccountScreen_Preview() { + AccountScreen( + accountName = "test@example.com", + showOnlyPersonal = AccountSettings.ShowOnlyPersonal(false, true), + hasCardDav = true, + canCreateAddressBook = false, + cardDavProgress = AccountProgress.Active, + addressBooks = null, + hasCalDav = true, + canCreateCalendar = true, + calDavProgress = AccountProgress.Pending, + calendars = null, + hasWebcal = true, + subscriptions = null + ) +} + +@Composable +@Preview +fun DeleteAccountDialog( + onConfirm: () -> Unit = {}, + onDismiss: () -> Unit = {} +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.account_delete_confirmation_title)) }, + text = { Text(stringResource(R.string.account_delete_confirmation_text)) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountScreenModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountScreenModel.kt new file mode 100644 index 00000000..b8289f41 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/AccountScreenModel.kt @@ -0,0 +1,191 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import android.accounts.Account +import android.app.Application +import android.provider.CalendarContract +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.asFlow +import androidx.lifecycle.switchMap +import androidx.lifecycle.viewModelScope +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.repository.AccountRepository +import at.bitfire.davdroid.repository.DavCollectionRepository +import at.bitfire.davdroid.repository.DavServiceRepository +import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker +import at.bitfire.davdroid.settings.AccountSettings +import at.bitfire.davdroid.syncadapter.OneTimeSyncWorker +import at.bitfire.davdroid.util.TaskUtils +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.util.logging.Level + +class AccountScreenModel @AssistedInject constructor( + val context: Application, + private val accountRepository: AccountRepository, + private val collectionRepository: DavCollectionRepository, + serviceRepository: DavServiceRepository, + accountProgressUseCase: AccountProgressUseCase, + getBindableHomesetsFromServiceUseCase: GetBindableHomeSetsFromServiceUseCase, + getServiceCollectionPagerUseCase: GetServiceCollectionPagerUseCase, + @Assisted val account: Account +): ViewModel() { + + @AssistedFactory + interface Factory { + fun create(account: Account): AccountScreenModel + } + + companion object { + fun factoryFromAccount(assistedFactory: Factory, account: Account) = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return assistedFactory.create(account) as T + } + } + } + + /** whether the account is invalid and the screen shall be closed */ + val invalidAccount = accountRepository.getAllFlow().map { accounts -> + !accounts.contains(account) + } + + private val settings = AccountSettings(context, account) + private val refreshSettingsSignal = MutableLiveData(Unit) + val showOnlyPersonal = refreshSettingsSignal.switchMap { + object : LiveData() { + init { + viewModelScope.launch(Dispatchers.IO) { + postValue(settings.getShowOnlyPersonal()) + } + } + } + }.asFlow() + fun setShowOnlyPersonal(showOnlyPersonal: Boolean) = viewModelScope.launch(Dispatchers.IO) { + settings.setShowOnlyPersonal(showOnlyPersonal) + refreshSettingsSignal.postValue(Unit) + } + + private val cardDavSvc = serviceRepository + .getCardDavServiceFlow(account.name) + .stateIn(viewModelScope, initialValue = null, started = SharingStarted.Eagerly) + val hasCardDav = cardDavSvc.map { it != null } + val bindableAddressBookHomesets = getBindableHomesetsFromServiceUseCase(cardDavSvc) + val canCreateAddressBook = bindableAddressBookHomesets.map { homeSets -> + homeSets.isNotEmpty() + } + val cardDavProgress: Flow = accountProgressUseCase( + account = account, + serviceFlow = cardDavSvc, + authoritiesFlow = flowOf(listOf(context.getString(R.string.address_books_authority))) + ) + val addressBooksPager = getServiceCollectionPagerUseCase(cardDavSvc, Collection.TYPE_ADDRESSBOOK, showOnlyPersonal) + + private val calDavSvc = serviceRepository + .getCalDavServiceFlow(account.name) + .stateIn(viewModelScope, initialValue = null, started = SharingStarted.Eagerly) + val hasCalDav = calDavSvc.map { it != null } + val bindableCalendarHomesets = getBindableHomesetsFromServiceUseCase(calDavSvc) + val canCreateCalendar = bindableCalendarHomesets.map { homeSets -> + homeSets.isNotEmpty() + } + private val tasksProvider = TaskUtils.currentProviderFlow(context, viewModelScope) + private val calDavAuthorities = tasksProvider.map { tasks -> + listOfNotNull(CalendarContract.AUTHORITY, tasks?.authority) + } + val calDavProgress = accountProgressUseCase( + account = account, + serviceFlow = calDavSvc, + authoritiesFlow = calDavAuthorities + ) + val calendarsPager = getServiceCollectionPagerUseCase(calDavSvc, Collection.TYPE_CALENDAR, showOnlyPersonal) + val hasWebcal = calDavSvc.map { service -> + if (service != null) + collectionRepository.anyWebcal(service.id) + else + false + } + val webcalPager = getServiceCollectionPagerUseCase(calDavSvc, Collection.TYPE_WEBCAL, showOnlyPersonal) + + + var error by mutableStateOf(null) + private set + + fun resetError() { error = null } + + + var showNoWebcalApp by mutableStateOf(false) + private set + + fun noWebcalApp() { showNoWebcalApp = true } + fun resetShowNoWebcalApp() { showNoWebcalApp = false } + + + // actions + + /** Deletes the account from the system (won't touch collections on the server). */ + fun deleteAccount() { + viewModelScope.launch { + accountRepository.delete(account.name) + } + } + + fun refreshCollections() { + cardDavSvc.value?.let { svc -> + RefreshCollectionsWorker.enqueue(context, svc.id) + } + calDavSvc.value?.let { svc -> + RefreshCollectionsWorker.enqueue(context, svc.id) + } + } + + /** + * Renames the [account] to given name. + * + * @param newName new account name + */ + fun renameAccount(newName: String) { + viewModelScope.launch { + try { + accountRepository.rename(account.name, newName) + + // synchronize again + val newAccount = Account(context.getString(R.string.account_type), newName) + OneTimeSyncWorker.enqueueAllAuthorities(context, newAccount, manual = true) + } catch (e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't rename account", e) + error = e.localizedMessage + } + } + } + + fun setCollectionSync(id: Long, sync: Boolean) { + viewModelScope.launch { + collectionRepository.setSync(id, sync) + } + } + + fun sync() { + OneTimeSyncWorker.enqueueAllAuthorities(context, account, manual = true) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionActivity.kt new file mode 100644 index 00000000..26e799ac --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionActivity.kt @@ -0,0 +1,54 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import android.accounts.Account +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.TaskStackBuilder +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.components.ActivityComponent + +@AndroidEntryPoint +class CollectionActivity: AppCompatActivity() { + + @EntryPoint + @InstallIn(ActivityComponent::class) + interface CollectionEntryPoint { + fun collectionModelAssistedFactory(): CollectionScreenModel.Factory + } + + companion object { + const val EXTRA_ACCOUNT = "account" + const val EXTRA_COLLECTION_ID = "collection_id" + } + + val account by lazy { intent.getParcelableExtra(EXTRA_ACCOUNT)!! } + val collectionId by lazy { intent.getLongExtra(EXTRA_COLLECTION_ID, -1) } + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + CollectionScreen( + collectionId = collectionId, + onFinish = ::finish, + onNavUp = ::onSupportNavigateUp + ) + } + } + + override fun supportShouldUpRecreateTask(targetIntent: Intent) = true + + override fun onPrepareSupportNavigateUpTaskStack(builder: TaskStackBuilder) { + builder.editIntentAt(builder.intentCount - 1)?.putExtra(AccountActivity.EXTRA_ACCOUNT, account) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionInfoDialog.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionInfoDialog.kt deleted file mode 100644 index 3cd5efcd..00000000 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionInfoDialog.kt +++ /dev/null @@ -1,150 +0,0 @@ -/* - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - */ - -package at.bitfire.davdroid.ui.account - -import android.text.format.DateUtils -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material.Card -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.lifecycle.viewmodel.compose.viewModel -import at.bitfire.davdroid.R -import at.bitfire.davdroid.db.Collection -import okhttp3.HttpUrl.Companion.toHttpUrl - -@Composable -fun CollectionPropertiesDialog( - collection: Collection, - onDismiss: () -> Unit, - model: AccountModel = viewModel() -) { - val owner by model.getCollectionOwner(collection).observeAsState() - val lastSynced by model.getCollectionLastSynced(collection).observeAsState(emptyMap()) - - Dialog(onDismissRequest = onDismiss) { - Card { - CollectionPropertiesContent( - collection = collection, - owner = owner, - lastSynced = lastSynced - ) - } - } -} - -@Composable -fun CollectionPropertiesContent( - collection: Collection, - owner: String?, - lastSynced: Map -) { - val context = LocalContext.current - - Column(Modifier.padding(16.dp)) { - // URL - Text(stringResource(R.string.collection_properties_url), style = MaterialTheme.typography.h5) - SelectionContainer { - Text( - collection.url.toString(), - style = MaterialTheme.typography.body2.copy(fontFamily = FontFamily.Monospace), - modifier = Modifier.padding(bottom = 16.dp) - ) - } - - // Owner - if (owner != null) { - Text(stringResource(R.string.collection_properties_owner), style = MaterialTheme.typography.h5) - Text( - text = owner, - style = MaterialTheme.typography.body2, - modifier = Modifier.padding(bottom = 16.dp) - ) - } - - // Last synced (for all applicable authorities) - Text(stringResource(R.string.collection_properties_sync_time), style = MaterialTheme.typography.h5) - if (lastSynced.isEmpty()) - Text( - stringResource(R.string.collection_properties_sync_time_never), - modifier = Modifier.padding(bottom = 8.dp) - ) - else - for ((app, timestamp) in lastSynced.entries) { - Text( - text = app, - style = MaterialTheme.typography.body2 - ) - - val timeStr = DateUtils.getRelativeDateTimeString( - context, timestamp, DateUtils.SECOND_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, 0 - ).toString() - Text( - text = timeStr, - style = MaterialTheme.typography.body2, - modifier = Modifier.padding(bottom = 8.dp) - ) - } - Spacer(Modifier.height(8.dp)) - - if (collection.supportsWebPush) { - collection.pushTopic?.let { topic -> - Text( - stringResource(R.string.collection_properties_push_support), - style = MaterialTheme.typography.h5 - ) - Text( - stringResource(R.string.collection_properties_push_support_web_push), - style = MaterialTheme.typography.body2, - modifier = Modifier.padding(bottom = 8.dp) - ) - } - - val subscribedStr = - collection.pushSubscriptionCreated?.let { timestamp -> - val timeStr = DateUtils.getRelativeDateTimeString( - context, timestamp, DateUtils.SECOND_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, 0 - ).toString() - stringResource(R.string.collection_properties_push_subscribed_at, timeStr) - } ?: stringResource(R.string.collection_properties_push_subscribed_never) - Text( - text = subscribedStr, - style = MaterialTheme.typography.body2, - modifier = Modifier.padding(bottom = 16.dp) - ) - } - } -} - -@Composable -@Preview -fun CollectionPropertiesDialog_Sample() { - CollectionPropertiesContent( - collection = Collection( - id = 1, - type = Collection.TYPE_ADDRESSBOOK, - url = "https://example.com".toHttpUrl(), - displayName = "Display Name", - description = "Description", - supportsWebPush = true, - pushTopic = "push-topic" - ), - owner = "Owner", - lastSynced = emptyMap() - ) -} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionScreen.kt new file mode 100644 index 00000000..c96e9747 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionScreen.kt @@ -0,0 +1,395 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import android.app.Activity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountBox +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.CloudSync +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.DoNotDisturbOn +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MediumTopAppBar +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import at.bitfire.davdroid.R +import at.bitfire.davdroid.repository.DavSyncStatsRepository +import at.bitfire.davdroid.ui.AppTheme +import at.bitfire.davdroid.ui.composable.ExceptionInfoDialog +import dagger.hilt.android.EntryPointAccessors +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +@Composable +fun CollectionScreen( + collectionId: Long, + onFinish: () -> Unit, + onNavUp: () -> Unit +) { + val context = LocalContext.current as Activity + val entryPoint = EntryPointAccessors.fromActivity(context, CollectionActivity.CollectionEntryPoint::class.java) + val model = viewModel( + factory = CollectionScreenModel.factoryFromCollection(entryPoint.collectionModelAssistedFactory(), collectionId) + ) + + val collectionOrNull by model.collection.collectAsStateWithLifecycle(null) + if (model.invalid) { + onFinish() + return + } + + val collection = collectionOrNull ?: return + CollectionScreen( + inProgress = model.inProgress, + error = model.error, + onResetError = model::resetError, + color = collection.color, + sync = collection.sync, + onSetSync = model::setSync, + privWriteContent = collection.privWriteContent, + forceReadOnly = collection.forceReadOnly, + onSetForceReadOnly = model::setForceReadOnly, + title = collection.title(), + displayName = collection.displayName, + description = collection.description, + owner = model.owner.collectAsStateWithLifecycle(null).value, + lastSynced = model.lastSynced.collectAsStateWithLifecycle(emptyList()).value, + url = collection.url.toString(), + onDelete = model::delete, + onNavUp = onNavUp + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CollectionScreen( + inProgress: Boolean, + error: Exception? = null, + onResetError: () -> Unit = {}, + color: Int?, + sync: Boolean, + onSetSync: (Boolean) -> Unit = {}, + privWriteContent: Boolean, + forceReadOnly: Boolean, + onSetForceReadOnly: (Boolean) -> Unit = {}, + title: String, + displayName: String? = null, + description: String? = null, + owner: String? = null, + lastSynced: List = emptyList(), + supportsWebPush: Boolean = false, + url: String, + onDelete: () -> Unit = {}, + onNavUp: () -> Unit = {} +) { + AppTheme { + if (error != null) + ExceptionInfoDialog( + exception = error, + onDismiss = onResetError + ) + + Scaffold( + topBar = { + MediumTopAppBar( + navigationIcon = { + IconButton(onClick = onNavUp) { + Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.navigate_up)) + } + }, + title = { + Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + actions = { + var showDeleteDialog by remember { mutableStateOf(false) } + IconButton( + onClick = { showDeleteDialog = true }, + enabled = !inProgress + ) { + Icon(Icons.Default.DeleteForever, contentDescription = stringResource(R.string.collection_delete)) + } + + if (showDeleteDialog) + DeleteCollectionDialog( + displayName = title, + onDismiss = { showDeleteDialog = false }, + onConfirm = { + onDelete() + showDeleteDialog = false + } + ) + } + ) + } + ) { padding -> + Column( + Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + ) { + if (inProgress) + LinearProgressIndicator( + Modifier + .fillMaxWidth() + .padding(bottom = 8.dp)) + + if (color != null) { + Box( + Modifier + .background(Color(color)) + .fillMaxWidth() + .height(16.dp) + ) + Spacer(Modifier.height(8.dp)) + } + + Column(Modifier.padding(8.dp)) { + CollectionScreen_Entry( + icon = Icons.Default.Sync, + title = stringResource(R.string.collection_synchronization), + text = + if (sync) + stringResource(R.string.collection_synchronization_on) + else + stringResource(R.string.collection_synchronization_off), + control = { + Switch( + checked = sync, + onCheckedChange = onSetSync + ) + } + ) + + CollectionScreen_Entry( + icon = Icons.Default.DoNotDisturbOn, + title = stringResource(R.string.collection_read_only), + text = when { + !privWriteContent -> stringResource(R.string.collection_read_only_by_server) + forceReadOnly -> stringResource(R.string.collection_read_only_forced) + else -> stringResource(R.string.collection_read_write) + }, + control = { + Switch( + checked = forceReadOnly || !privWriteContent, + enabled = privWriteContent, + onCheckedChange = onSetForceReadOnly + ) + } + ) + + if (displayName != null) + CollectionScreen_Entry( + title = stringResource(R.string.collection_title), + text = title + ) + + if (description != null) + CollectionScreen_Entry( + title = stringResource(R.string.collection_description), + text = description + ) + + if (owner != null) + CollectionScreen_Entry( + icon = Icons.Default.AccountBox, + title = stringResource(R.string.collection_owner), + text = owner + ) + + if (supportsWebPush) + CollectionScreen_Entry( + icon = Icons.Default.CloudSync, + title = stringResource(R.string.collection_push_support), + text = stringResource(R.string.collection_push_web_push) + ) + + Column(Modifier.padding(start = 44.dp)) { + if (sync && lastSynced.isNotEmpty()) { + val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) + + for (lastSync in lastSynced) { + Text( + text = stringResource(R.string.collection_last_sync, lastSync.appName), + style = MaterialTheme.typography.titleMedium + ) + + val time = ZonedDateTime.ofInstant(Instant.ofEpochMilli(lastSync.lastSynced), ZoneId.systemDefault()) + Text( + text = formatter.format(time), + style = MaterialTheme.typography.bodyLarge + ) + + Spacer(Modifier.height(16.dp)) + } + } + + Text( + text = stringResource(R.string.collection_url), + style = MaterialTheme.typography.titleMedium + ) + SelectionContainer { + Text( + text = url, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + modifier = Modifier + ) + } + } + } + } + } + } +} + +@Composable +fun CollectionScreen_Entry( + icon: ImageVector? = null, + title: String? = null, + text: String? = null, + control: @Composable (() -> Unit)? = null +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 16.dp) + ) { + if (icon != null) + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier + .padding(end = 12.dp) + .size(32.dp) + ) + else + Spacer(Modifier.width(44.dp)) + + Column(Modifier.weight(1f)) { + if (title != null) + Text( + text = title, + style = MaterialTheme.typography.titleMedium + ) + + if (text != null) + Text( + text = text, + style = MaterialTheme.typography.bodyLarge + ) + } + + if (control != null) + control() + } +} + +@Composable +@Preview +fun CollectionScreen_Preview() { + CollectionScreen( + inProgress = true, + color = 0xff14c0c4.toInt(), + sync = true, + privWriteContent = true, + forceReadOnly = false, + url = "https://example.com/calendar", + title = "Some Calendar, with some additional text to make it wrap around and stuff.", + displayName = "Some Calendar, with some additional text to make it wrap around and stuff.", + description = "This is some description of the calendar. It can be long and wrap around.", + owner = "Some One", + lastSynced = listOf( + DavSyncStatsRepository.LastSynced( + appName = "Some Content Provider", + lastSynced = 1234567890 + ) + ), + supportsWebPush = true + ) +} + + +@Composable +fun DeleteCollectionDialog( + displayName: String, + onDismiss: () -> Unit = {}, + onConfirm: () -> Unit = {} +) { + AlertDialog( + icon = { + Icon(Icons.Default.DeleteForever, contentDescription = null) + }, + title = { + Text(stringResource(R.string.collection_delete)) + }, + text = { + Text(stringResource(R.string.collection_delete_warning, displayName)) + }, + confirmButton = { + Button(onClick = onConfirm) { + Text(stringResource(R.string.dialog_delete)) + } + }, + dismissButton = { + OutlinedButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + }, + onDismissRequest = onDismiss + ) +} + +@Composable +@Preview +fun DeleteCollectionDialog_Preview() { + DeleteCollectionDialog( + displayName = "Some Calendar" + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionScreenModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionScreenModel.kt new file mode 100644 index 00000000..2837e13f --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionScreenModel.kt @@ -0,0 +1,112 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.repository.DavCollectionRepository +import at.bitfire.davdroid.repository.DavSyncStatsRepository +import at.bitfire.davdroid.util.lastSegment +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class CollectionScreenModel @AssistedInject constructor( + @Assisted val collectionId: Long, + db: AppDatabase, + private val collectionRepository: DavCollectionRepository, + syncStatsRepository: DavSyncStatsRepository +): ViewModel() { + + @AssistedFactory + interface Factory { + fun create(collectionId: Long): CollectionScreenModel + } + + companion object { + fun factoryFromCollection(assistedFactory: Factory, collectionId: Long) = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return assistedFactory.create(collectionId) as T + } + } + } + + var invalid by mutableStateOf(false) + val collection = collectionRepository.getFlow(collectionId) + .map { + if (it == null) + invalid = true + it + } + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + + private val principalDao = db.principalDao() + val owner: Flow = collection.map { collection -> + collection?.ownerId?.let { ownerId -> + val principal = principalDao.getAsync(ownerId) + principal.displayName ?: principal.url.lastSegment() + } + } + + val lastSynced = syncStatsRepository.getLastSyncedFlow(collectionId) + + /** Whether an operation (like deleting the collection) is currently in progress */ + var inProgress by mutableStateOf(false) + private set + + var error by mutableStateOf(null) + private set + + /** Scope for operations that must not be cancelled. */ + private val noCancellationScope = CoroutineScope(SupervisorJob()) + + /** + * Deletes the collection from the database and the server. + */ + fun delete() { + val collection = collection.value ?: return + + inProgress = true + noCancellationScope.launch { + try { + collectionRepository.delete(collection) + } catch (e: Exception) { + error = e + } finally { + inProgress = false + } + } + } + + fun resetError() { + error = null + } + + fun setForceReadOnly(forceReadOnly: Boolean) { + viewModelScope.launch { + collectionRepository.setForceReadOnly(collectionId, forceReadOnly) + } + } + + fun setSync(sync: Boolean) { + viewModelScope.launch { + collectionRepository.setSync(collectionId, sync) + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionsList.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionsList.kt index 8e2d9eb1..108b8988 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionsList.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CollectionsList.kt @@ -5,46 +5,43 @@ package at.bitfire.davdroid.ui.account import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight +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.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.Checkbox -import androidx.compose.material.DropdownMenu -import androidx.compose.material.DropdownMenuItem -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Switch -import androidx.compose.material.Text -import androidx.compose.material.TextButton import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.EventNote -import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Contacts import androidx.compose.material.icons.filled.RemoveCircle +import androidx.compose.material.icons.filled.Task import androidx.compose.material.icons.filled.Today -import androidx.compose.material.icons.outlined.Task +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.Switch +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.paging.compose.LazyPagingItems @@ -57,13 +54,13 @@ import okhttp3.HttpUrl.Companion.toHttpUrl fun CollectionsList( collections: LazyPagingItems, onChangeSync: (collectionId: Long, sync: Boolean) -> Unit, - onChangeForceReadOnly: (collectionId: Long, forceReadOnly: Boolean) -> Unit, modifier: Modifier = Modifier, - onSubscribe: (collection: Collection) -> Unit = {} + onSubscribe: (collection: Collection) -> Unit = {}, + onCollectionDetails: ((collection: Collection) -> Unit)? = null ) { LazyColumn( - contentPadding = PaddingValues(vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.Top), + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Top), modifier = modifier ) { items( @@ -72,162 +69,129 @@ fun CollectionsList( ) { index -> collections[index]?.let { item -> if (item.type == Collection.TYPE_WEBCAL) - CollectionList_Subscription( + CollectionsList_Item_Webcal( item, - onSubscribe = { - onSubscribe(item) - } + onSubscribe = { onSubscribe(item) } ) else - CollectionList_Item( + CollectionsList_Item_Standard( item, - onChangeSync = { sync -> - onChangeSync(item.id, sync) - }, - onChangeForceReadOnly = { forceReadOnly -> - onChangeForceReadOnly(item.id, forceReadOnly) - } + onChangeSync = { onChangeSync(item.id, it) }, + onCollectionDetails = onCollectionDetails ) } } + + // make sure we can scroll down far enough so that the last item is not covered by a FAB + item { + Spacer(Modifier.height(140.dp)) + } } } @OptIn(ExperimentalLayoutApi::class) @Composable fun CollectionList_Item( - collection: Collection, - onChangeSync: (sync: Boolean) -> Unit = {}, - onChangeForceReadOnly: (forceReadOnly: Boolean) -> Unit = {} + color: Color? = null, + title: String, + description: String? = null, + addressBook: Boolean = false, + calendar: Boolean = false, + todoList: Boolean = false, + journal: Boolean = false, + readOnly: Boolean = false, + onShowDetails: (() -> Unit)? = null, + syncControl: @Composable () -> Unit ) { - val context = LocalContext.current + var modifier = Modifier.fillMaxWidth() + if (onShowDetails != null) + modifier = modifier.clickable(onClick = onShowDetails) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.height(IntrinsicSize.Min) + OutlinedCard( + elevation = CardDefaults.cardElevation(1.dp), + modifier = modifier ) { - if (collection.type == Collection.TYPE_CALENDAR) { - val color = collection.color?.let { Color(it) } ?: Color.Transparent - Box( - Modifier - .background(color) - .fillMaxHeight() - .width(4.dp) - ) - } + Box { + Column { + if (color != null) + Box( + Modifier + .background(color) + .fillMaxWidth() + .height(8.dp) + ) - Switch( - checked = collection.sync, - onCheckedChange = onChangeSync, - modifier = Modifier - .padding(horizontal = 4.dp) - .semantics { - contentDescription = context.getString(R.string.account_synchronize_this_collection) - } - ) + Column(Modifier.padding(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold)) - Column( - modifier = Modifier.weight(1f) - ) { - Text( - collection.title(), - style = MaterialTheme.typography.body1 - ) - collection.description?.let { description -> - Text( - description, - style = MaterialTheme.typography.body2 - ) - } - } - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(start = 8.dp) - ) { - FlowRow( - verticalArrangement = Arrangement.Center, - horizontalArrangement = Arrangement.End, - maxItemsInEachRow = 2, - modifier = Modifier.fillMaxHeight() - ) { - if (collection.readOnly()) - Icon(Icons.Default.RemoveCircle, stringResource(R.string.account_read_only)) - if (collection.supportsVEVENT == true) - Icon(Icons.Default.Today, stringResource(R.string.account_calendar)) - if (collection.supportsVTODO == true) - Icon(Icons.Outlined.Task, stringResource(R.string.account_task_list)) - if (collection.supportsVJOURNAL == true) - Icon(Icons.AutoMirrored.Default.EventNote, stringResource(R.string.account_journal)) - } - - var showOverflow by remember { mutableStateOf(false) } - var showPropertiesDialog by remember { mutableStateOf(false) } - var showDeleteCollectionDialog by remember { mutableStateOf(false) } - - IconButton(onClick = { showOverflow = true }) { - Icon(Icons.Default.MoreVert, stringResource(R.string.options_menu)) - } - DropdownMenu( - expanded = showOverflow, - onDismissRequest = { showOverflow = false } - ) { - // force read-only (only show for collections that are modifiable on the server) - if (collection.privWriteContent) - DropdownMenuItem( - onClick = { - onChangeForceReadOnly(!collection.forceReadOnly) - showOverflow = false + if (description != null) + Text(description, style = MaterialTheme.typography.bodyMedium) } + + syncControl() + } + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.collection_force_read_only)) - Checkbox( - checked = collection.readOnly(), - onCheckedChange = { forceReadOnly -> - onChangeForceReadOnly(forceReadOnly) - showOverflow = false - } - ) - } - } + if (addressBook) + CollectionList_Item_Chip(Icons.Default.Contacts, stringResource(R.string.account_contacts)) - // show properties - DropdownMenuItem(onClick = { - showPropertiesDialog = true - showOverflow = false - }) { - Text(stringResource(R.string.collection_properties)) + if (calendar) + CollectionList_Item_Chip(Icons.Default.Today, stringResource(R.string.account_calendar)) + if (todoList) + CollectionList_Item_Chip(Icons.Default.Task, stringResource(R.string.account_task_list)) + if (journal) + CollectionList_Item_Chip(Icons.AutoMirrored.Default.EventNote, stringResource(R.string.account_journal)) + + if (readOnly) + CollectionList_Item_Chip(Icons.Default.RemoveCircle, stringResource(R.string.account_read_only)) + } } - - // delete collection (only show when required privilege is available) - if (collection.privUnbind) - DropdownMenuItem(onClick = { - showDeleteCollectionDialog = true - showOverflow = false - }) { - Text(stringResource(R.string.delete_collection)) - } } - - if (showDeleteCollectionDialog) - DeleteCollectionDialog( - collection = collection, - onDismiss = { showDeleteCollectionDialog = false } - ) - if (showPropertiesDialog) - CollectionPropertiesDialog( - collection = collection, - onDismiss = { showPropertiesDialog = false } - ) } } } @Composable -@Preview -fun CollectionsList_Item_Sample() { +fun CollectionsList_Item_Standard( + collection: Collection, + onChangeSync: (sync: Boolean) -> Unit = {}, + onCollectionDetails: ((collection: Collection) -> Unit)? = null +) { CollectionList_Item( + color = collection.color?.let { Color(it) }, + title = collection.title(), + description = collection.description, + addressBook = collection.type == Collection.TYPE_ADDRESSBOOK, + calendar = collection.supportsVEVENT == true, + todoList = collection.supportsVTODO == true, + journal = collection.supportsVJOURNAL == true, + readOnly = collection.readOnly(), + onShowDetails = { + if (onCollectionDetails != null) + onCollectionDetails(collection) + } + ) { + val context = LocalContext.current + Switch( + checked = collection.sync, + onCheckedChange = onChangeSync, + modifier = Modifier + .padding(start = 4.dp, top = 4.dp, bottom = 4.dp) + .semantics { + contentDescription = context.getString(R.string.account_synchronize_this_collection) + } + ) + } +} + +@Composable +@Preview +fun CollectionsList_Item_Standard_Preview() { + CollectionsList_Item_Standard( Collection( type = Collection.TYPE_CALENDAR, url = "https://example.com/caldav/sample".toHttpUrl(), @@ -243,50 +207,30 @@ fun CollectionsList_Item_Sample() { } @Composable -fun CollectionList_Subscription( - item: Collection, +fun CollectionsList_Item_Webcal( + collection: Collection, onSubscribe: () -> Unit = {} ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.height(IntrinsicSize.Min) + CollectionList_Item( + color = collection.color?.let { Color(it) }, + title = collection.title(), + description = collection.description, + calendar = true, + readOnly = true ) { - val color = item.color?.let { Color(it) } ?: Color.Transparent - Box( - Modifier - .background(color) - .fillMaxHeight() - .width(4.dp) - ) - - Column( - modifier = Modifier - .padding(horizontal = 8.dp) - .weight(1f) + OutlinedButton( + onClick = onSubscribe, + modifier = Modifier.padding(start = 4.dp) ) { - Text( - item.title(), - style = MaterialTheme.typography.body1 - ) - item.description?.let { description -> - Text( - description, - style = MaterialTheme.typography.body2 - ) - } - } - - TextButton(onClick = onSubscribe) { - Text("Subscribe".uppercase()) + Text("Subscribe") } } - } @Composable @Preview -fun CollectionList_Subscription_Preview() { - CollectionList_Subscription( +fun CollectionList_Item_Webcal_Preview() { + CollectionsList_Item_Webcal( Collection( type = Collection.TYPE_WEBCAL, url = "https://example.com/caldav/sample".toHttpUrl(), @@ -295,4 +239,13 @@ fun CollectionList_Subscription_Preview() { color = 0xffff0000.toInt() ) ) +} + +@Composable +fun CollectionList_Item_Chip(icon: ImageVector, text: String) { + SuggestionChip( + icon = { Icon(icon, contentDescription = text) }, + label = { Text(text) }, + onClick = {} + ) } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateAddressBookActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateAddressBookActivity.kt index e0e8ddc8..40e8915f 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateAddressBookActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateAddressBookActivity.kt @@ -8,243 +8,47 @@ import android.accounts.Account import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent -import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.LinearProgressIndicator -import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedTextField -import androidx.compose.material.Scaffold -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import androidx.core.app.TaskStackBuilder -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import at.bitfire.davdroid.R -import at.bitfire.davdroid.db.HomeSet -import at.bitfire.davdroid.ui.M2Theme -import at.bitfire.davdroid.ui.widget.ExceptionInfoDialog +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn import dagger.hilt.android.AndroidEntryPoint -import okhttp3.HttpUrl.Companion.toHttpUrl -import org.apache.commons.lang3.StringUtils -import java.util.UUID -import javax.inject.Inject +import dagger.hilt.android.components.ActivityComponent @AndroidEntryPoint class CreateAddressBookActivity: AppCompatActivity() { + @EntryPoint + @InstallIn(ActivityComponent::class) + interface CreateAddressBookEntryPoint { + fun createAddressBookModelAssistedFactory(): CreateAddressBookModel.Factory + } + companion object { const val EXTRA_ACCOUNT = "account" } - val account by lazy { intent.getParcelableExtra(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set") } - - @Inject - lateinit var modelFactory: AccountModel.Factory - val model by viewModels { - object: ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = - modelFactory.create(account) as T - } + val account by lazy { + intent.getParcelableExtra(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set") } - + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { - M2Theme { - var displayName by remember { mutableStateOf("") } - var description by remember { mutableStateOf("") } - var homeSet by remember { mutableStateOf(null) } - - var isCreating by remember { mutableStateOf(false) } - model.createCollectionResult.observeAsState().value?.let { result -> - if (result.isEmpty) - finish() - else - ExceptionInfoDialog( - exception = result.get(), - onDismiss = { - isCreating = false - model.createCollectionResult.value = null - } - ) - } - - val onCreateCollection = { - if (!isCreating) { - isCreating = true - homeSet?.let { homeSet -> - model.createCollection( - homeSet = homeSet, - addressBook = true, - name = UUID.randomUUID().toString(), - displayName = StringUtils.trimToNull(displayName), - description = StringUtils.trimToNull(description) - ) - } - } - } - - val homeSets by model.bindableAddressBookHomesets.observeAsState() - - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.create_addressbook)) }, - navigationIcon = { - IconButton(onClick = { onSupportNavigateUp() }) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.navigate_up)) - } - }, - actions = { - val isCreateEnabled = !isCreating && displayName.isNotEmpty() && homeSet != null - IconButton( - enabled = isCreateEnabled, - onClick = { onCreateCollection() } - ) { - Text(stringResource(R.string.create_collection_create).uppercase()) - } - } - ) - } - ) { padding -> - Column( - Modifier - .padding(padding) - .verticalScroll(rememberScrollState()) - ) { - if (isCreating) - LinearProgressIndicator( - color = MaterialTheme.colors.secondary, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp) - ) - - homeSets?.let { homeSets -> - AddressBookForm( - displayName = displayName, - onDisplayNameChange = { displayName = it }, - description = description, - onDescriptionChange = { description = it }, - homeSets = homeSets, - homeSet = homeSet, - onHomeSetSelected = { homeSet = it }, - onCreateCollection = { - onCreateCollection() - } - ) - } - } - } - } + CreateAddressBookScreen( + account = account, + onNavUp = ::onSupportNavigateUp, + onFinish = ::finish + ) } } override fun supportShouldUpRecreateTask(targetIntent: Intent) = true override fun onPrepareSupportNavigateUpTaskStack(builder: TaskStackBuilder) { - builder.editIntentAt(builder.intentCount - 1)?.putExtra(AccountActivity.EXTRA_ACCOUNT, model.account) - } - - - @Composable - private fun AddressBookForm( - displayName: String, - onDisplayNameChange: (String) -> Unit = {}, - description: String, - onDescriptionChange: (String) -> Unit = {}, - homeSet: HomeSet?, - homeSets: List, - onHomeSetSelected: (HomeSet) -> Unit = {}, - onCreateCollection: () -> Unit = {} - ) { - Column(Modifier - .fillMaxWidth() - .padding(8.dp) - ) { - val focusRequester = remember { FocusRequester() } - OutlinedTextField( - value = displayName, - onValueChange = onDisplayNameChange, - label = { Text(stringResource(R.string.create_collection_display_name)) }, - singleLine = true, - keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Next - ), - modifier = Modifier - .fillMaxWidth() - .focusRequester(focusRequester) - ) - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } - - OutlinedTextField( - value = description, - onValueChange = onDescriptionChange, - label = { Text(stringResource(R.string.create_collection_description_optional)) }, - singleLine = true, - keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Done - ), - keyboardActions = KeyboardActions( - onDone = { - onCreateCollection() - } - ), - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) - ) - - HomeSetSelection( - homeSet = homeSet, - homeSets = homeSets, - onHomeSetSelected = onHomeSetSelected - ) - } - } - - @Composable - @Preview - private fun AddressBookForm_Preview() { - AddressBookForm( - displayName = "Display Name", - description = "Some longer description that is optional", - homeSets = listOf( - HomeSet(1, 0, false, "http://example.com/".toHttpUrl()), - HomeSet(2, 0, false, "http://example.com/".toHttpUrl(), displayName = "Home Set 2") - ), - homeSet = null - ) + builder.editIntentAt(builder.intentCount - 1)?.putExtra(AccountActivity.EXTRA_ACCOUNT, account) } } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateAddressBookModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateAddressBookModel.kt new file mode 100644 index 00000000..6be709e7 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateAddressBookModel.kt @@ -0,0 +1,107 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import android.accounts.Account +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import at.bitfire.davdroid.db.HomeSet +import at.bitfire.davdroid.repository.DavCollectionRepository +import at.bitfire.davdroid.repository.DavHomeSetRepository +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +class CreateAddressBookModel @AssistedInject constructor( + private val collectionRepository: DavCollectionRepository, + homeSetRepository: DavHomeSetRepository, + @Assisted val account: Account +): ViewModel() { + + @AssistedFactory + interface Factory { + fun create(account: Account): CreateAddressBookModel + } + + companion object { + fun factoryFromAccount(assistedFactory: Factory, account: Account) = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return assistedFactory.create(account) as T + } + } + } + + + val addressBookHomeSets = homeSetRepository.getAddressBookHomeSetsFlow(account) + + + // UI state + + data class UiState( + val error: Exception? = null, + val success: Boolean = false, + + val displayName: String = "", + val description: String = "", + val selectedHomeSet: HomeSet? = null, + val isCreating: Boolean = false + ) { + val canCreate = !isCreating && displayName.isNotBlank() && selectedHomeSet != null + } + + var uiState by mutableStateOf(UiState()) + private set + + fun resetError() { + uiState = uiState.copy(error = null) + } + + fun setDisplayName(displayName: String) { + uiState = uiState.copy(displayName = displayName) + } + + fun setDescription(description: String) { + uiState = uiState.copy(description = description) + } + + fun setHomeSet(homeSet: HomeSet) { + uiState = uiState.copy(selectedHomeSet = homeSet) + } + + + // actions + + /* Creating collections shouldn't be cancelled when the view is destroyed, otherwise we might + end up with collections on the server that are not represented in the database/UI. */ + private val createCollectionScope = CoroutineScope(SupervisorJob()) + + fun createAddressBook() { + val homeSet = uiState.selectedHomeSet ?: return + uiState = uiState.copy(isCreating = true) + + createCollectionScope.launch { + uiState = try { + collectionRepository.createAddressBook( + account = account, + homeSet = homeSet, + displayName = uiState.displayName, + description = uiState.description + ) + + uiState.copy(isCreating = false, success = true) + } catch (e: Exception) { + uiState.copy(isCreating = false, error = e) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateAddressBookScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateAddressBookScreen.kt new file mode 100644 index 00000000..a2896eb0 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateAddressBookScreen.kt @@ -0,0 +1,207 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import android.accounts.Account +import android.app.Activity +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.HomeSet +import at.bitfire.davdroid.ui.AppTheme +import at.bitfire.davdroid.ui.composable.ExceptionInfoDialog +import dagger.hilt.android.EntryPointAccessors +import okhttp3.HttpUrl.Companion.toHttpUrl + +@Composable +fun CreateAddressBookScreen( + account: Account, + onNavUp: () -> Unit = {}, + onFinish: () -> Unit = {} +) { + val context = LocalContext.current as Activity + val entryPoint = EntryPointAccessors.fromActivity(context, CreateAddressBookActivity.CreateAddressBookEntryPoint::class.java) + val model = viewModel( + factory = CreateAddressBookModel.factoryFromAccount(entryPoint.createAddressBookModelAssistedFactory(), account) + ) + val uiState = model.uiState + + if (uiState.success) + onFinish() + + CreateAddressBookScreen( + error = uiState.error, + onResetError = model::resetError, + displayName = uiState.displayName, + onSetDisplayName = model::setDisplayName, + description = uiState.description, + onSetDescription = model::setDescription, + homeSets = model.addressBookHomeSets.collectAsStateWithLifecycle(emptyList()).value, + selectedHomeSet = uiState.selectedHomeSet, + onSelectHomeSet = model::setHomeSet, + canCreate = uiState.canCreate, + isCreating = uiState.isCreating, + onCreate = model::createAddressBook, + onNavUp = onNavUp + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CreateAddressBookScreen( + error: Exception? = null, + onResetError: () -> Unit = {}, + displayName: String = "", + onSetDisplayName: (String) -> Unit = {}, + description: String = "", + onSetDescription: (String) -> Unit = {}, + homeSets: List, + selectedHomeSet: HomeSet? = null, + onSelectHomeSet: (HomeSet) -> Unit = {}, + canCreate: Boolean = false, + isCreating: Boolean = false, + onCreate: () -> Unit = {}, + onNavUp: () -> Unit = {} +) { + AppTheme { + if (error != null) + ExceptionInfoDialog( + exception = error, + onDismiss = onResetError + ) + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.create_addressbook)) }, + navigationIcon = { + IconButton(onClick = onNavUp) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.navigate_up)) + } + } + ) + } + ) { padding -> + Column( + Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + ) { + if (isCreating) + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) + + Column( + Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + val focusRequester = remember { FocusRequester() } + OutlinedTextField( + value = displayName, + onValueChange = onSetDisplayName, + label = { Text(stringResource(R.string.create_collection_display_name)) }, + singleLine = true, + enabled = !isCreating, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + ) + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + OutlinedTextField( + value = description, + onValueChange = onSetDescription, + label = { Text(stringResource(R.string.create_collection_description_optional)) }, + supportingText = { Text(stringResource(R.string.create_collection_optional)) }, + singleLine = true, + enabled = !isCreating, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + onCreate() + } + ), + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) + + HomeSetSelection( + homeSet = selectedHomeSet, + homeSets = homeSets, + onSelectHomeSet = onSelectHomeSet, + enabled = !isCreating, + modifier = Modifier.padding(vertical = 8.dp) + ) + + Text( + stringResource(R.string.create_addressbook_maybe_not_supported), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(vertical = 8.dp) + ) + + Button( + onClick = onCreate, + enabled = canCreate + ) { + Text(stringResource(R.string.create_addressbook)) + } + } + } + } + } +} + +@Composable +@Preview +fun CreateAddressBookScreen_Preview() { + CreateAddressBookScreen( + displayName = "Address Book", + homeSets = listOf( + HomeSet(0, 0, true, "https://example.com/some/homeset".toHttpUrl()) + ) + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCalendarActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCalendarActivity.kt index 9a63d42b..fa39736e 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCalendarActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCalendarActivity.kt @@ -8,204 +8,42 @@ import android.accounts.Account import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent -import androidx.activity.viewModels -import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Checkbox -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.LinearProgressIndicator -import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedTextField -import androidx.compose.material.Scaffold -import androidx.compose.material.Text -import androidx.compose.material.TextButton -import androidx.compose.material.TopAppBar -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableIntStateOf -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.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalInspectionMode -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import androidx.core.app.TaskStackBuilder -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import at.bitfire.davdroid.Constants -import at.bitfire.davdroid.R -import at.bitfire.davdroid.db.HomeSet -import at.bitfire.davdroid.ui.M2Theme -import at.bitfire.davdroid.ui.composable.MultipleChoiceInputDialog -import at.bitfire.davdroid.ui.widget.CalendarColorPickerDialog -import at.bitfire.davdroid.ui.widget.ExceptionInfoDialog +import at.bitfire.davdroid.ui.AppTheme +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn import dagger.hilt.android.AndroidEntryPoint -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import org.apache.commons.lang3.StringUtils -import java.text.Collator -import java.time.ZoneId -import java.time.format.TextStyle -import java.util.Locale -import java.util.UUID -import javax.inject.Inject +import dagger.hilt.android.components.ActivityComponent @AndroidEntryPoint class CreateCalendarActivity: AppCompatActivity() { + @EntryPoint + @InstallIn(ActivityComponent::class) + interface CreateCalendarEntryPoint { + fun createCalendarModelAssistedFactory(): CreateCalendarModel.Factory + } + companion object { const val EXTRA_ACCOUNT = "account" } - val account by lazy { intent.getParcelableExtra(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set") } - - @Inject - lateinit var modelFactory: AccountModel.Factory - val accountModel by viewModels { - object: ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = - modelFactory.create(account) as T - } + val account by lazy { + intent.getParcelableExtra(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set") } - val model: Model by viewModels() - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { - M2Theme { - var displayName by remember { mutableStateOf("") } - var color by remember { mutableIntStateOf(Constants.DAVDROID_GREEN_RGBA) } - var description by remember { mutableStateOf("") } - var homeSet by remember { mutableStateOf(null) } - var timeZoneId by remember { mutableStateOf(ZoneId.systemDefault().id) } - var supportVEVENT by remember { mutableStateOf(true) } - var supportVTODO by remember { mutableStateOf(false) } - var supportVJOURNAL by remember { mutableStateOf(false) } - - var isCreating by remember { mutableStateOf(false) } - accountModel.createCollectionResult.observeAsState().value?.let { result -> - if (result.isEmpty) - finish() - else - ExceptionInfoDialog( - exception = result.get(), - onDismiss = { - isCreating = false - accountModel.createCollectionResult.value = null - } - ) - } - - val onCreateCollection = { - if (!isCreating) { - isCreating = true - homeSet?.let { homeSet -> - accountModel.createCollection( - homeSet = homeSet, - addressBook = false, - name = UUID.randomUUID().toString(), - displayName = StringUtils.trimToNull(displayName), - description = StringUtils.trimToNull(description), - color = color, - timeZoneId = timeZoneId, - supportsVEVENT = supportVEVENT, - supportsVTODO = supportVTODO, - supportsVJOURNAL = supportVJOURNAL - ) - } - } - } - - val homeSets by accountModel.bindableCalendarHomesets.observeAsState() - - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.create_calendar)) }, - navigationIcon = { - IconButton(onClick = { onSupportNavigateUp() }) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.navigate_up)) - } - }, - actions = { - val isCreateEnabled = !isCreating && displayName.isNotBlank() && homeSet != null - IconButton( - enabled = isCreateEnabled, - onClick = { onCreateCollection() } - ) { - Text(stringResource(R.string.create_collection_create).uppercase()) - } - } - ) - } - ) { padding -> - Column(Modifier - .padding(padding) - .verticalScroll(rememberScrollState()) - ) { - if (isCreating) - LinearProgressIndicator( - color = MaterialTheme.colors.secondary, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp) - ) - - homeSets?.let { homeSets -> - CalendarForm( - displayName = displayName, - onDisplayNameChange = { displayName = it }, - color = color, - onColorChange = { color = it }, - description = description, - onDescriptionChange = { description = it }, - timeZoneId = timeZoneId, - onTimeZoneSelected = { timeZoneId = it }, - supportVEVENT = supportVEVENT, - onSupportVEVENTChange = { supportVEVENT = it }, - supportVTODO = supportVTODO, - onSupportVTODOChange = { supportVTODO = it }, - supportVJOURNAL = supportVJOURNAL, - onSupportVJOURNALChange = { supportVJOURNAL = it }, - homeSet = homeSet, - homeSets = homeSets, - onHomeSetSelected = { homeSet = it } - ) - } - } - } + AppTheme { + CreateCalendarScreen( + account = account, + onNavUp = ::onSupportNavigateUp, + onFinish = ::finish + ) } } } @@ -213,225 +51,7 @@ class CreateCalendarActivity: AppCompatActivity() { override fun supportShouldUpRecreateTask(targetIntent: Intent) = true override fun onPrepareSupportNavigateUpTaskStack(builder: TaskStackBuilder) { - builder.editIntentAt(builder.intentCount - 1)?.putExtra(AccountActivity.EXTRA_ACCOUNT, accountModel.account) - } - - - @Composable - fun CalendarForm( - displayName: String, - onDisplayNameChange: (String) -> Unit = {}, - color: Int, - onColorChange: (Int) -> Unit = {}, - description: String, - onDescriptionChange: (String) -> Unit = {}, - timeZoneId: String, - onTimeZoneSelected: (String) -> Unit = {}, - supportVEVENT: Boolean, - onSupportVEVENTChange: (Boolean) -> Unit = {}, - supportVTODO: Boolean, - onSupportVTODOChange: (Boolean) -> Unit = {}, - supportVJOURNAL: Boolean, - onSupportVJOURNALChange: (Boolean) -> Unit = {}, - homeSet: HomeSet?, - homeSets: List, - onHomeSetSelected: (HomeSet) -> Unit = {} - ) { - Column(Modifier - .fillMaxWidth() - .padding(8.dp) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - val focusRequester = remember { FocusRequester() } - OutlinedTextField( - value = displayName, - onValueChange = onDisplayNameChange, - label = { Text(stringResource(R.string.create_collection_display_name)) }, - singleLine = true, - keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Next - ), - modifier = Modifier - .weight(1f) - .padding(end = 8.dp) - .focusRequester(focusRequester) - ) - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } - - var showColorPicker by remember { mutableStateOf(false) } - Box(Modifier - .background(color = Color(color), shape = CircleShape) - .clickable { - showColorPicker = true - } - .size(32.dp) - ) - if (showColorPicker) { - CalendarColorPickerDialog( - onSelectColor = { - onColorChange(it) - showColorPicker = false - }, - onDismiss = { showColorPicker = false } - ) - } - } - - OutlinedTextField( - value = description, - onValueChange = onDescriptionChange, - label = { Text(stringResource(R.string.create_collection_description_optional)) }, - singleLine = true, - keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Next - ), - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) - ) - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp) - ) { - Column(Modifier.weight(1f)) { - Text( - stringResource(R.string.create_calendar_time_zone), - style = MaterialTheme.typography.body1 - ) - Text( - ZoneId.of(timeZoneId).getDisplayName(TextStyle.FULL_STANDALONE, Locale.getDefault()), - style = MaterialTheme.typography.body2 - ) - } - - var showTimeZoneDialog by remember { mutableStateOf(false) } - TextButton( - enabled = - if (LocalInspectionMode.current) - true - else - model.timeZoneDefs.observeAsState().value != null, - onClick = { showTimeZoneDialog = true } - ) { - Text("Select timezone".uppercase()) - } - if (showTimeZoneDialog) { - model.timeZoneDefs.observeAsState().value?.let { timeZoneDefs -> - MultipleChoiceInputDialog( - title = "Select timezone", - namesAndValues = timeZoneDefs, - initialValue = timeZoneId, - onValueSelected = { - onTimeZoneSelected(it) - showTimeZoneDialog = false - }, - onDismiss = { showTimeZoneDialog = false } - ) - } - } - } - - Text( - stringResource(R.string.create_calendar_type), - style = MaterialTheme.typography.body1, - modifier = Modifier.padding(top = 16.dp) - ) - CheckboxRow( - labelId = R.string.create_calendar_type_vevent, - checked = supportVEVENT, - onCheckedChange = onSupportVEVENTChange - ) - CheckboxRow( - labelId = R.string.create_calendar_type_vtodo, - checked = supportVTODO, - onCheckedChange = onSupportVTODOChange - ) - CheckboxRow( - labelId = R.string.create_calendar_type_vjournal, - checked = supportVJOURNAL, - onCheckedChange = onSupportVJOURNALChange - ) - - HomeSetSelection( - homeSet = homeSet, - homeSets = homeSets, - onHomeSetSelected = onHomeSetSelected - ) - } - } - - @Composable - fun CheckboxRow( - @StringRes labelId: Int, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Checkbox( - checked = checked, - onCheckedChange = onCheckedChange - ) - Text( - text = stringResource(labelId), - style = MaterialTheme.typography.body1, - modifier = Modifier - .clickable { onCheckedChange(!checked) } - .weight(1f) - ) - } - } - - @Composable - @Preview - fun CalendarForm_Preview() { - CalendarForm( - displayName = "My Calendar", - color = Color.Magenta.toArgb(), - description = "This is my calendar", - timeZoneId = "Europe/Vienna", - supportVEVENT = true, - supportVTODO = false, - supportVJOURNAL = false, - homeSet = null, - homeSets = emptyList() - ) - } - - - @HiltViewModel - class Model @Inject constructor() : ViewModel() { - - /** - * List of available time zones as pairs. - */ - val timeZoneDefs = MutableLiveData>>() - - init { - viewModelScope.launch(Dispatchers.IO) { - val timeZones = mutableListOf>() - - // iterate over Android time zones and take those with ical4j VTIMEZONE into consideration - val locale = Locale.getDefault() - for (id in ZoneId.getAvailableZoneIds()) { - timeZones += Pair( - ZoneId.of(id).getDisplayName(TextStyle.FULL, locale), - id - ) - } - - val collator = Collator.getInstance() - timeZoneDefs.postValue(timeZones.sortedBy { collator.getCollationKey(it.first) }) - } - } - + builder.editIntentAt(builder.intentCount - 1)?.putExtra(AccountActivity.EXTRA_ACCOUNT, account) } } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCalendarModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCalendarModel.kt new file mode 100644 index 00000000..572916a1 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCalendarModel.kt @@ -0,0 +1,168 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import android.accounts.Account +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import at.bitfire.davdroid.db.HomeSet +import at.bitfire.davdroid.repository.DavCollectionRepository +import at.bitfire.davdroid.repository.DavHomeSetRepository +import at.bitfire.ical4android.Css3Color +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.launch +import java.text.Collator +import java.time.ZoneId +import java.time.format.TextStyle +import java.util.Locale +import java.util.TimeZone + +class CreateCalendarModel @AssistedInject constructor( + private val collectionRepository: DavCollectionRepository, + homeSetRepository: DavHomeSetRepository, + @Assisted val account: Account +): ViewModel() { + + @AssistedFactory + interface Factory { + fun create(account: Account): CreateCalendarModel + } + + companion object { + fun factoryFromAccount(assistedFactory: Factory, account: Account) = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return assistedFactory.create(account) as T + } + } + } + + + val calendarHomeSets = homeSetRepository.getCalendarHomeSetsFlow(account) + + data class TimeZoneInfo( + val id: String, + val displayName: String, + ) + + /** List of available time zones as pairs. */ + val timeZones: Flow> = flow { + val timeZones = mutableListOf() + val locale = Locale.getDefault() + for (id in ZoneId.getAvailableZoneIds()) + timeZones += TimeZoneInfo( + id, + ZoneId.of(id).getDisplayName(TextStyle.FULL, locale), + ) + + val collator = Collator.getInstance() + val result = timeZones.sortedBy { collator.getCollationKey(it.displayName) } + + emit(result) + }.flowOn(Dispatchers.Default) + + + // UI state + + data class UiState( + val error: Exception? = null, + val success: Boolean = false, + + val color: Int = Css3Color.entries.random().argb, + val displayName: String = "", + val description: String = "", + val timeZoneId: String? = TimeZone.getDefault().id, + val supportVEVENT: Boolean = true, + val supportVTODO: Boolean = true, + val supportVJOURNAL: Boolean = true, + val homeSet: HomeSet? = null, + val isCreating: Boolean = false + ) { + val canCreate = !isCreating && displayName.isNotBlank() && homeSet != null + } + + var uiState by mutableStateOf(UiState()) + private set + + fun resetError() { + uiState = uiState.copy(error = null) + } + + fun setColor(color: Int) { + uiState = uiState.copy(color = color) + } + + fun setDisplayName(displayName: String) { + uiState = uiState.copy(displayName = displayName) + } + + fun setDescription(description: String) { + uiState = uiState.copy(description = description) + } + + fun setTimeZoneId(timeZoneId: String?) { + uiState = uiState.copy(timeZoneId = timeZoneId) + } + + fun setSupportVEVENT(supportVEVENT: Boolean) { + uiState = uiState.copy(supportVEVENT = supportVEVENT) + } + + fun setSupportVTODO(supportVTODO: Boolean) { + uiState = uiState.copy(supportVTODO = supportVTODO) + } + + fun setSupportVJOURNAL(supportVJOURNAL: Boolean) { + uiState = uiState.copy(supportVJOURNAL = supportVJOURNAL) + } + + fun setHomeSet(homeSet: HomeSet) { + uiState = uiState.copy(homeSet = homeSet) + } + + + // actions + + /* Creating collections shouldn't be cancelled when the view is destroyed, otherwise we might + end up with collections on the server that are not represented in the database/UI. */ + private val createCollectionScope = CoroutineScope(SupervisorJob()) + + fun createCalendar() { + val homeSet = uiState.homeSet ?: return + uiState = uiState.copy(isCreating = true) + + createCollectionScope.launch { + uiState = try { + collectionRepository.createCalendar( + account = account, + homeSet = homeSet, + color = uiState.color, + displayName = uiState.displayName, + description = uiState.description, + timeZoneId = uiState.timeZoneId, + supportVEVENT = uiState.supportVEVENT, + supportVTODO = uiState.supportVTODO, + supportVJOURNAL = uiState.supportVJOURNAL + ) + + uiState.copy(isCreating = false, success = true) + } catch (e: Exception) { + uiState.copy(isCreating = false, error = e) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCalendarScreen.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCalendarScreen.kt new file mode 100644 index 00000000..4d08d9bd --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCalendarScreen.kt @@ -0,0 +1,376 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import android.accounts.Account +import android.app.Activity +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.HomeSet +import at.bitfire.davdroid.ui.AppTheme +import at.bitfire.davdroid.ui.composable.ExceptionInfoDialog +import at.bitfire.davdroid.ui.widget.CalendarColorPickerDialog +import at.bitfire.ical4android.Css3Color +import dagger.hilt.android.EntryPointAccessors +import okhttp3.HttpUrl.Companion.toHttpUrl + +@Composable +fun CreateCalendarScreen( + account: Account, + onFinish: () -> Unit, + onNavUp: () -> Unit +) { + val context = LocalContext.current as Activity + val entryPoint = EntryPointAccessors.fromActivity(context, CreateCalendarActivity.CreateCalendarEntryPoint::class.java) + val model = viewModel( + factory = CreateCalendarModel.factoryFromAccount(entryPoint.createCalendarModelAssistedFactory(), account) + ) + val uiState = model.uiState + + if (uiState.success) + onFinish() + + CreateCalendarScreen( + isCreating = uiState.isCreating, + error = uiState.error, + onResetError = model::resetError, + color = uiState.color, + onSetColor = model::setColor, + displayName = uiState.displayName, + onSetDisplayName = model::setDisplayName, + description = uiState.description, + onSetDescription = model::setDescription, + timeZones = model.timeZones.collectAsStateWithLifecycle(emptyList()).value, + timeZone = uiState.timeZoneId, + onSelectTimeZone = model::setTimeZoneId, + supportVEVENT = uiState.supportVEVENT, + onSetSupportVEVENT = model::setSupportVEVENT, + supportVTODO = uiState.supportVTODO, + onSetSupportVTODO = model::setSupportVTODO, + supportVJOURNAL = uiState.supportVJOURNAL, + onSetSupportVJOURNAL = model::setSupportVJOURNAL, + homeSets = model.calendarHomeSets.collectAsStateWithLifecycle(emptyList()).value, + selectedHomeSet = uiState.homeSet, + onSelectHomeSet = model::setHomeSet, + canCreate = uiState.canCreate, + onCreate = model::createCalendar, + onNavUp = onNavUp + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CreateCalendarScreen( + error: Exception? = null, + onResetError: () -> Unit = {}, + color: Int = Css3Color.green.argb, + onSetColor: (Int) -> Unit = {}, + displayName: String = "", + onSetDisplayName: (String) -> Unit = {}, + description: String = "", + onSetDescription: (String) -> Unit = {}, + timeZones: List, + timeZone: String? = null, + onSelectTimeZone: (String?) -> Unit = {}, + supportVEVENT: Boolean = true, + onSetSupportVEVENT: (Boolean) -> Unit = {}, + supportVTODO: Boolean = true, + onSetSupportVTODO: (Boolean) -> Unit = {}, + supportVJOURNAL: Boolean = true, + onSetSupportVJOURNAL: (Boolean) -> Unit = {}, + homeSets: List, + selectedHomeSet: HomeSet? = null, + onSelectHomeSet: (HomeSet) -> Unit = {}, + canCreate: Boolean = false, + isCreating: Boolean = false, + onCreate: () -> Unit = {}, + onNavUp: () -> Unit = {} +) { + val context = LocalContext.current + + AppTheme { + if (error != null) + ExceptionInfoDialog( + exception = error, + onDismiss = onResetError + ) + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.create_calendar)) }, + navigationIcon = { + IconButton(onClick = onNavUp) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.navigate_up)) + } + } + ) + } + ) { padding -> + Column( + Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + ) { + if (isCreating) + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) + + Column( + Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + val focusRequester = remember { FocusRequester() } + OutlinedTextField( + value = displayName, + onValueChange = onSetDisplayName, + label = { Text(stringResource(R.string.create_collection_display_name)) }, + singleLine = true, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next + ), + modifier = Modifier + .weight(1f) + .focusRequester(focusRequester) + .padding(end = 8.dp) + ) + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + var showColorPicker by remember { mutableStateOf(false) } + Box(Modifier + .background(color = Color(color), shape = RoundedCornerShape(4.dp)) + .clickable { + showColorPicker = true + } + .size(48.dp) + .semantics { + contentDescription = context.getString(R.string.create_collection_color) + } + ) + if (showColorPicker) { + CalendarColorPickerDialog( + onSelectColor = { color -> + onSetColor(color) + showColorPicker = false + }, + onDismiss = { showColorPicker = false } + ) + } + } + + OutlinedTextField( + value = description, + onValueChange = onSetDescription, + label = { Text(stringResource(R.string.create_collection_description_optional)) }, + supportingText = { Text(stringResource(R.string.create_collection_optional)) }, + singleLine = true, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { onCreate() } + ), + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) + + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = Modifier.padding(top = 8.dp) + ) { + OutlinedTextField( + label = { Text(stringResource(R.string.create_calendar_time_zone_optional)) }, + value = timeZone ?: stringResource(R.string.create_calendar_time_zone_none), + onValueChange = { /* read-only */ }, + supportingText = { Text(stringResource(R.string.create_collection_optional)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + readOnly = true, + modifier = Modifier + .fillMaxWidth() + .menuAnchor() + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.fillMaxHeight() + ) { + Text( + text = stringResource(R.string.create_calendar_time_zone_none), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .clickable { + onSelectTimeZone(null) + expanded = false + } + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + ) + + for (tz in timeZones) + Text( + text = tz.displayName, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .clickable { + onSelectTimeZone(tz.id) + expanded = false + } + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + } + + Text( + stringResource(R.string.create_calendar_type), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(top = 16.dp) + ) + CheckBoxRow( + label = stringResource(R.string.create_calendar_type_vevent), + value = supportVEVENT, + onValueChange = onSetSupportVEVENT + ) + CheckBoxRow( + label = stringResource(R.string.create_calendar_type_vtodo), + value = supportVTODO, + onValueChange = onSetSupportVTODO + ) + CheckBoxRow( + label = stringResource(R.string.create_calendar_type_vjournal), + value = supportVJOURNAL, + onValueChange = onSetSupportVJOURNAL + ) + + HomeSetSelection( + homeSet = selectedHomeSet, + homeSets = homeSets, + onSelectHomeSet = onSelectHomeSet, + modifier = Modifier.padding(vertical = 8.dp) + ) + + Text( + stringResource(R.string.create_calendar_maybe_not_supported), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(vertical = 8.dp) + ) + + Button( + onClick = onCreate, + enabled = canCreate + ) { + Text(stringResource(R.string.create_calendar)) + } + } + } + } + } +} + +@Composable +fun CheckBoxRow( + label: String, + value: Boolean, + onValueChange: (Boolean) -> Unit +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { onValueChange(!value) } + ) { + Checkbox( + checked = value, + onCheckedChange = onValueChange + ) + Text( + label, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +@Preview +fun CreateCalendarScreenPreview() { + CreateCalendarScreen( + timeZones = listOf( + CreateCalendarModel.TimeZoneInfo( + id = "Europe/Vienna", + displayName = "Vienna (Europe)" + ) + ), + timeZone = "Europe/Vienna", + + homeSets = listOf( + HomeSet( + id = 0, + serviceId = 0, + personal = true, + url = "https://example.com/some/homeset".toHttpUrl() + ) + ) + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCollectionComposables.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCollectionComposables.kt deleted file mode 100644 index 503bce80..00000000 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/CreateCollectionComposables.kt +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - */ - -package at.bitfire.davdroid.ui.account - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material.MaterialTheme -import androidx.compose.material.RadioButton -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.unit.dp -import at.bitfire.davdroid.R -import at.bitfire.davdroid.db.HomeSet -import at.bitfire.davdroid.util.DavUtils - -@Composable -fun HomeSetSelection( - homeSet: HomeSet?, - homeSets: List, - onHomeSetSelected: (HomeSet) -> Unit -) { - // select first home set if none is selected - LaunchedEffect(homeSets) { - if (homeSet == null) - homeSets.firstOrNull()?.let(onHomeSetSelected) - } - - Text( - text = stringResource(R.string.create_collection_home_set), - style = MaterialTheme.typography.body1, - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp, bottom = 8.dp) - ) - for (item in homeSets) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton( - selected = homeSet == item, - onClick = { onHomeSetSelected(item) } - ) - Column( - Modifier - .clickable { onHomeSetSelected(item) } - .weight(1f)) { - Text( - text = item.displayName ?: DavUtils.lastSegmentOfUrl(item.url), - style = MaterialTheme.typography.body1 - ) - Text( - text = item.url.encodedPath, - style = MaterialTheme.typography.caption.copy(fontFamily = FontFamily.Monospace) - ) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/DeleteCollectionDialog.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/DeleteCollectionDialog.kt deleted file mode 100644 index 3e197414..00000000 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/DeleteCollectionDialog.kt +++ /dev/null @@ -1,205 +0,0 @@ -/* - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - */ - -package at.bitfire.davdroid.ui.account - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material.Card -import androidx.compose.material.Checkbox -import androidx.compose.material.LinearProgressIndicator -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.lifecycle.viewmodel.compose.viewModel -import at.bitfire.davdroid.R -import at.bitfire.davdroid.db.Collection -import at.bitfire.davdroid.ui.widget.ExceptionInfoDialog -import okhttp3.HttpUrl.Companion.toHttpUrl -import kotlin.jvm.optionals.getOrNull - -@Composable -fun DeleteCollectionDialog( - collection: Collection, - onDismiss: () -> Unit, - model: AccountModel = viewModel() -) { - var started by remember { mutableStateOf(false) } - val result by model.deleteCollectionResult.observeAsState() - - fun dismiss() { - // dismiss dialog, reset data so that it can be shown from start again - onDismiss() - started = false - model.deleteCollectionResult.value = null - } - - if (result?.isEmpty == true) { - // finished without error - dismiss() - return - } - - Dialog( - properties = DialogProperties( - dismissOnClickOutside = - result != null || // finished with error message - !started // not started - ), - onDismissRequest = ::dismiss - ) { - Card { - DeleteCollectionDialog_Content( - collection = collection, - started = started, - result = result?.getOrNull(), - onDeleteCollection = { - started = true - model.deleteCollection(collection) - }, - onCancel = ::dismiss - ) - } - } -} - -@Composable -fun DeleteCollectionDialog_Content( - collection: Collection, - started: Boolean, - result: Exception?, - onDeleteCollection: () -> Unit = {}, - onCancel: () -> Unit = {} -) { - Column(Modifier.padding(16.dp)) { - Text( - stringResource(R.string.delete_collection_confirm_title), - style = MaterialTheme.typography.h6 - ) - - Text( - stringResource(R.string.delete_collection_confirm_warning, collection.title()), - style = MaterialTheme.typography.body1, - modifier = Modifier.padding(vertical = 8.dp) - ) - - when { - result != null -> ExceptionInfoDialog( - exception = result, - remoteResource = collection.url, - onDismiss = onCancel - ) - - started -> { - LinearProgressIndicator( - color = MaterialTheme.colors.secondary, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp) - ) - } - - else -> - DeleteCollectionDialog_Confirmation( - onDeleteCollection = onDeleteCollection, - onCancel = onCancel - ) - } - - } -} - -@Composable -fun DeleteCollectionDialog_Confirmation( - onDeleteCollection: () -> Unit = {}, - onCancel: () -> Unit = {} -) { - var confirmed by remember { mutableStateOf(false) } - Row(Modifier.padding(vertical = 8.dp)) { - Checkbox( - checked = confirmed, - onCheckedChange = { - confirmed = !confirmed - } - ) - Text( - stringResource(R.string.delete_collection_data_shall_be_deleted), - style = MaterialTheme.typography.body1, - modifier = Modifier.clickable { confirmed = !confirmed } - ) - } - - Row( - horizontalArrangement = Arrangement.End, - modifier = Modifier.fillMaxWidth() - ) { - TextButton( - onClick = onCancel - ) { - Text(stringResource(android.R.string.cancel).uppercase()) - } - - TextButton( - onClick = onDeleteCollection, - enabled = confirmed - ) { - Text(stringResource(R.string.delete_collection).uppercase()) - } - } -} - -@Composable -@Preview -fun DeleteCollectionDialog_Preview() { - DeleteCollectionDialog_Content( - collection = Collection( - type = Collection.TYPE_CALENDAR, - url = "https://example.com/calendar".toHttpUrl(), - ), - started = false, - result = null - ) -} - -@Composable -@Preview -fun DeleteCollectionDialog_Preview_Deleting() { - DeleteCollectionDialog_Content( - collection = Collection( - type = Collection.TYPE_CALENDAR, - url = "https://example.com/calendar".toHttpUrl(), - ), - started = true, - result = null - ) -} - -@Composable -@Preview -fun DeleteCollectionDialog_Preview_Error() { - DeleteCollectionDialog_Content( - collection = Collection( - type = Collection.TYPE_CALENDAR, - url = "https://example.com/calendar".toHttpUrl(), - ), - started = true, - result = Exception("Test error") - ) -} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/GetBindableHomeSetsFromServiceUseCase.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/GetBindableHomeSetsFromServiceUseCase.kt new file mode 100644 index 00000000..42bbc17b --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/GetBindableHomeSetsFromServiceUseCase.kt @@ -0,0 +1,31 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.HomeSet +import at.bitfire.davdroid.db.Service +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import javax.inject.Inject + +class GetBindableHomeSetsFromServiceUseCase @Inject constructor( + db: AppDatabase +) { + + private val homeSetDao = db.homeSetDao() + + @OptIn(ExperimentalCoroutinesApi::class) + operator fun invoke(serviceFlow: Flow): Flow> = + serviceFlow.flatMapLatest { service -> + if (service == null) + flowOf(emptyList()) + else + homeSetDao.getBindableByServiceFlow(service.id) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/GetServiceCollectionPagerUseCase.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/GetServiceCollectionPagerUseCase.kt new file mode 100644 index 00000000..362516cf --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/GetServiceCollectionPagerUseCase.kt @@ -0,0 +1,45 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import at.bitfire.davdroid.db.AppDatabase +import at.bitfire.davdroid.db.Collection +import at.bitfire.davdroid.db.Service +import at.bitfire.davdroid.settings.AccountSettings +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import javax.inject.Inject + +class GetServiceCollectionPagerUseCase @Inject constructor( + val db: AppDatabase +) { + + companion object { + const val PAGER_SIZE = 20 + } + + operator fun invoke( + serviceFlow: Flow, + collectionType: String, + showOnlyPersonalFlow: Flow + ): Flow?> = + combine(serviceFlow, showOnlyPersonalFlow) { service, onlyPersonal -> + if (service == null) + return@combine null + + Pager( + config = PagingConfig(PAGER_SIZE), + pagingSourceFactory = { + if (onlyPersonal?.onlyPersonal == true) + db.collectionDao().pagePersonalByServiceAndType(service.id, collectionType) + else + db.collectionDao().pageByServiceAndType(service.id, collectionType) + } + ) + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/account/HomeSetSelection.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/HomeSetSelection.kt new file mode 100644 index 00000000..97f9e0d1 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/account/HomeSetSelection.kt @@ -0,0 +1,122 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.ui.account + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import at.bitfire.davdroid.R +import at.bitfire.davdroid.db.HomeSet +import okhttp3.HttpUrl.Companion.toHttpUrl + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeSetSelection( + homeSet: HomeSet?, + homeSets: List, + onSelectHomeSet: (HomeSet) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true +) { + Column(modifier) { + // select first home set if none is selected + LaunchedEffect(homeSets) { + if (homeSet == null) + homeSets.firstOrNull()?.let(onSelectHomeSet) + } + + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + label = { Text(stringResource(R.string.create_collection_home_set)) }, + value = homeSet?.title() ?: "", + onValueChange = { /* read-only */ }, + readOnly = true, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor() + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + Column(Modifier.padding(horizontal = 8.dp) + ) { + for (item in homeSets) { + Column( + modifier = Modifier + .clickable(enabled = enabled) { + onSelectHomeSet(item) + expanded = false + } + .padding(vertical = 8.dp) + ) { + Text( + text = item.title(), + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = item.url.encodedPath, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + } + } +} + +@Composable +@Preview +fun HomeSetSelection_Preview() { + val homeSets = listOf( + HomeSet( + id = 0, + serviceId = 0, + personal = true, + url = "https://example.com/homeset/first".toHttpUrl() + ), + HomeSet( + id = 0, + serviceId = 0, + personal = true, + url = "https://example.com/homeset/second".toHttpUrl() + ) + ) + HomeSetSelection( + homeSet = homeSets.last(), + homeSets = homeSets, + onSelectHomeSet = {} + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/Assistant.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/Assistant.kt index 7eb546bf..51c2dc09 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/Assistant.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/Assistant.kt @@ -14,9 +14,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.BottomAppBar import androidx.compose.material3.Button -import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/widget/ExceptionInfoDialog.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/ExceptionInfoDialog.kt similarity index 76% rename from app/src/main/kotlin/at/bitfire/davdroid/ui/widget/ExceptionInfoDialog.kt rename to app/src/main/kotlin/at/bitfire/davdroid/ui/composable/ExceptionInfoDialog.kt index 080bcafa..2a162469 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/widget/ExceptionInfoDialog.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/ExceptionInfoDialog.kt @@ -2,24 +2,25 @@ * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. */ -package at.bitfire.davdroid.ui.widget +package at.bitfire.davdroid.ui.composable import android.accounts.Account import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material.AlertDialog -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.TextButton import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Error +import androidx.compose.material3.AlertDialog +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.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import at.bitfire.dav4jvm.exception.HttpException import at.bitfire.davdroid.R @@ -52,14 +53,16 @@ fun ExceptionInfoDialog( Icon(Icons.Rounded.Error, null) Text( text = stringResource(titleRes), - modifier = Modifier.weight(1f).padding(start = 8.dp) + modifier = Modifier + .weight(1f) + .padding(start = 8.dp) ) } }, text = { Text( exception::class.java.name + "\n" + exception.localizedMessage, - style = MaterialTheme.typography.body1 + style = MaterialTheme.typography.bodyLarge ) }, dismissButton = { @@ -73,13 +76,22 @@ fun ExceptionInfoDialog( context.startActivity(intent.build()) } ) { - Text(stringResource(R.string.exception_show_details).uppercase()) + Text(stringResource(R.string.exception_show_details)) } }, confirmButton = { TextButton(onClick = onDismiss) { - Text(stringResource(android.R.string.ok).uppercase()) + Text(stringResource(android.R.string.ok)) } } ) +} + +@Composable +@Preview +fun ExceptionInfoDialog_Preview() { + ExceptionInfoDialog( + exception = Exception("Test exception"), + onDismiss = {} + ) } \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/SelectClientCertificateCard.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/SelectClientCertificateCard.kt index 3ccbdc48..d8ba7505 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/SelectClientCertificateCard.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/composable/SelectClientCertificateCard.kt @@ -8,7 +8,6 @@ import android.app.Activity import android.os.Build import android.security.KeyChain import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroActivity.kt index f187c8d3..be9f182a 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/intro/IntroActivity.kt @@ -26,8 +26,8 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.AndroidViewModel import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.ui.M2Theme import at.bitfire.davdroid.ui.M2Colors +import at.bitfire.davdroid.ui.M2Theme import com.github.appintro.AppIntro2 import dagger.hilt.EntryPoint import dagger.hilt.InstallIn diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AdvancedLoginModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AdvancedLoginModel.kt index 384faf1c..53e2f0f9 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AdvancedLoginModel.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AdvancedLoginModel.kt @@ -11,8 +11,6 @@ import androidx.lifecycle.ViewModel import at.bitfire.davdroid.db.Credentials import at.bitfire.davdroid.util.DavUtils.toURIorNull import org.apache.commons.lang3.StringUtils -import java.net.URI -import java.net.URISyntaxException class AdvancedLoginModel: ViewModel() { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/DetectResourcesPage.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/DetectResourcesPage.kt index d36256b6..d87eead7 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/DetectResourcesPage.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/DetectResourcesPage.kt @@ -4,7 +4,6 @@ package at.bitfire.davdroid.ui.setup -import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginDetailsPage.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginDetailsPage.kt index 26b35f0a..b5bae394 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginDetailsPage.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginDetailsPage.kt @@ -4,7 +4,6 @@ package at.bitfire.davdroid.ui.setup -import androidx.activity.compose.BackHandler import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.lifecycle.viewmodel.compose.viewModel diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginScreenModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginScreenModel.kt index 4c4dcce9..af1a7e22 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginScreenModel.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginScreenModel.kt @@ -13,10 +13,10 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import at.bitfire.davdroid.log.Logger +import at.bitfire.davdroid.repository.AccountRepository import at.bitfire.davdroid.servicedetection.DavResourceFinder import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.davdroid.settings.SettingsManager -import at.bitfire.davdroid.syncadapter.AccountRepository import at.bitfire.vcard4android.GroupMethod import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/UrlLoginModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/UrlLoginModel.kt index 37571e93..394f9795 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/UrlLoginModel.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/UrlLoginModel.kt @@ -10,12 +10,7 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import at.bitfire.davdroid.db.Credentials import at.bitfire.davdroid.util.DavUtils.toURIorNull -import dagger.hilt.android.lifecycle.HiltViewModel -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.apache.commons.lang3.StringUtils -import java.net.URI -import java.net.URISyntaxException -import javax.inject.Inject class UrlLoginModel: ViewModel() { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/util/DavUtils.kt b/app/src/main/kotlin/at/bitfire/davdroid/util/DavUtils.kt index 6c0b8ec7..29a1cfb5 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/util/DavUtils.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/util/DavUtils.kt @@ -60,6 +60,7 @@ object DavUtils { return String.format(Locale.ROOT, "#%06X%02X", color, alpha) } + @Deprecated("Use HttpUrl.lastSegment in UrlUtils") fun lastSegmentOfUrl(url: HttpUrl): String { // the list returned by HttpUrl.pathSegments() is unmodifiable, so we have to create a copy val segments = LinkedList(url.pathSegments) @@ -214,4 +215,4 @@ object DavUtils { null } -} +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/bitfire/davdroid/util/UrlUtils.kt b/app/src/main/kotlin/at/bitfire/davdroid/util/UrlUtils.kt new file mode 100644 index 00000000..63c5dba2 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/util/UrlUtils.kt @@ -0,0 +1,10 @@ +/* + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + */ + +package at.bitfire.davdroid.util + +import okhttp3.HttpUrl + +fun HttpUrl.lastSegment(): String = + pathSegments.lastOrNull { it.isNotEmpty() } ?: "/" \ No newline at end of file diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 499f627f..0b88425b 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -334,7 +334,7 @@ Crea una llibreta d\'adreces Crea un calendari - Zona horària predeterminada + Zona horària predeterminada Possibles entrades de calendari Esdeveniments Tasques diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 7a801b37..fabfdaac 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -320,7 +320,7 @@ Adressbuch erstellen Kalender anlegen - Standardzeitzone + Standardzeitzone Mögliche Kalendereinträge Termine Aufgaben diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml index 0f3adb64..0d6cfcf1 100644 --- a/app/src/main/res/values-eu/strings.xml +++ b/app/src/main/res/values-eu/strings.xml @@ -333,7 +333,7 @@ Sortu helbide liburua Sortu egutegia - Ordu-zona lehenetsia + Ordu-zona lehenetsia Egutegi sarrera posibleak Gertaerak Zereginak diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 80977a50..98277615 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -335,7 +335,7 @@ Crear libreta de enderezos Crear calendario - Zona horaria por defecto + Zona horaria por defecto Entradas posibles no calendario Eventos Tarefas diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 1235432c..718ad11b 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -331,7 +331,7 @@ アドレス帳を作成 カレンダーを作成 - デフォルトのタイムゾーン + デフォルトのタイムゾーン 可能なカレンダーエントリー 予定 タスク diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index 74a9ab42..c6889f58 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -159,7 +159,7 @@ Opprett adressebok Min adressebok Opprett kalender - Tidssone + Tidssone Mulige kalenderhendelser Hendelser Oppgaver diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 428116a3..fc5842b9 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -334,7 +334,7 @@ Adresboek aanmaken Kalender aanmaken - Standaard tijdzone + Standaard tijdzone Mogelijke kalender-items Gebeurtenissen Taken diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 3a23e306..475bcff0 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -336,7 +336,7 @@ Creează agendă de adrese Creează un calendar - Fus orar implicit + Fus orar implicit Posibile intrări din calendar Evenimente Sarcini diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index b9b3b33b..7371d14f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -338,7 +338,7 @@ Создать адресную книгу Создать календарь - Часовой пояс по умолчанию + Часовой пояс по умолчанию Возможные записи календаря События Задачи diff --git a/app/src/main/res/values-sl-rSI/strings.xml b/app/src/main/res/values-sl-rSI/strings.xml index 7fad7746..e1ce5026 100644 --- a/app/src/main/res/values-sl-rSI/strings.xml +++ b/app/src/main/res/values-sl-rSI/strings.xml @@ -167,7 +167,7 @@ Ustvari imenik Moj imenik Ustvari koledar - Časovna zona + Časovna zona Mogoči koledarski vnosi Dogodki Naloge diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index ec81392f..7372f76f 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -332,7 +332,7 @@ 创建通讯录 创建日历 - 默认时区 + 默认时区 可能使用的日历类型 事件 任务 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1bcd1e73..84d001d3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9,6 +9,7 @@ DAVx⁵ Address book at.bitfire.davdroid.addressbooks Address books + Delete Remove Cancel This field is required @@ -116,7 +117,7 @@ View/share Disable - + CalDAV/CardDAV Sync Adapter About / License Beta feedback @@ -209,7 +210,7 @@ Tasks app No compatible tasks app found - + CardDAV CalDAV Webcal @@ -229,12 +230,12 @@ synchronize this collection read-only calendar - task list + contacts journal + tasks Show only personal - Refresh collections - Refreshing collection list - "Webcal subscriptions can be synchronized with external apps." + Refresh list + Webcal subscriptions can be synchronized with external apps. No Webcal-capable app found Install ICSx⁵ @@ -376,38 +377,41 @@ Groups are per-contact categories - + Create address book + Address book creation over CardDAV may not be supported by the server. Create calendar - Default time zone + Default time zone* + Possible calendar entries Events Tasks Notes / journal + Calendar creation over CalDAV may not be supported by the server. Color - Creating collection Title - Title is required - Description - Description (optional) - optional Storage location - Storage location is required + Description* Create - Delete collection - Are you sure? - This collection (%s) and all its data will be removed permanently. - These data shall be deleted from the server. - Force read-only - Properties - Last synced - Never synced - Address (URL) - Owner - Push support - Yes (over Web Push) - Subscribed at %s - Not yet subscribed + * optional + + + Delete collection + This collection (%s) and all its data will be removed permanently, both locally and on the server. + Synchronization + Synchronization enabled + Synchronization disabled + Read-only + Read-only (by server) + Read-only (only locally) + Read/write + Title + Description + Owner + Push support + Server advertises Push support + Last sync (%s) + Address (URL) at.bitfire.davdroid.debug