mirror of
https://github.com/bitfireAT/davx5-ose
synced 2024-07-21 10:41:47 +00:00
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
This commit is contained in:
parent
c33ea84c77
commit
b13c6b0e6f
|
@ -140,6 +140,9 @@
|
|||
android:parentActivityName=".ui.AccountsActivity"
|
||||
android:exported="true">
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.account.CollectionActivity"
|
||||
android:parentActivityName=".ui.account.AccountActivity" />
|
||||
<activity
|
||||
android:name=".ui.account.CreateAddressBookActivity"
|
||||
android:parentActivityName=".ui.account.AccountActivity" />
|
||||
|
|
|
@ -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<List<Int>>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE id=:id")
|
||||
fun get(id: Long): Collection?
|
||||
|
||||
@Query("SELECT * FROM collection WHERE id=:id")
|
||||
fun getLive(id: Long): LiveData<Collection>
|
||||
fun getFlow(id: Long): Flow<Collection?>
|
||||
|
||||
@Query("SELECT * FROM collection WHERE serviceId=:serviceId")
|
||||
fun getByService(serviceId: Long): List<Collection>
|
||||
|
@ -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<Collection>
|
||||
|
||||
@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.
|
||||
|
|
|
@ -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
|
||||
)
|
||||
) {
|
||||
|
||||
fun title() = displayName ?: url.lastSegment()
|
||||
|
||||
}
|
|
@ -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<HomeSet>
|
||||
|
||||
@Query("SELECT * FROM homeset WHERE serviceId=:serviceId AND privBind")
|
||||
fun getBindableByService(serviceId: Long): List<HomeSet>
|
||||
@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<List<HomeSet>>
|
||||
|
||||
@Query("SELECT * FROM homeset WHERE serviceId=:serviceId AND privBind")
|
||||
fun getLiveBindableByService(serviceId: Long): LiveData<List<HomeSet>>
|
||||
fun getBindableByServiceFlow(serviceId: Long): Flow<List<HomeSet>>
|
||||
|
||||
@Insert
|
||||
fun insert(homeSet: HomeSet): Long
|
||||
|
|
|
@ -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<Principal?>
|
||||
suspend fun getAsync(id: Long): Principal
|
||||
|
||||
@Query("SELECT * FROM principal WHERE serviceId=:serviceId")
|
||||
fun getByService(serviceId: Long): List<Principal>
|
||||
|
|
|
@ -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<Service?>
|
||||
|
||||
@Query("SELECT id FROM service WHERE accountName=:accountName")
|
||||
fun getIdsByAccount(accountName: String): List<Long>
|
||||
fun getByAccountAndTypeFlow(accountName: String, type: String): Flow<Service?>
|
||||
|
||||
@Query("SELECT id FROM service WHERE accountName=:accountName")
|
||||
suspend fun getIdsByAccountAsync(accountName: String): List<Long>
|
||||
|
||||
@Query("SELECT id FROM service WHERE accountName=:accountName AND type=:type")
|
||||
fun getIdByAccountAndType(accountName: String, type: String): LiveData<Long>
|
||||
|
||||
@Query("SELECT type, id FROM service WHERE accountName=:accountName")
|
||||
fun getServiceTypeAndIdsByAccount(accountName: String): LiveData<List<ServiceTypeAndId>>
|
||||
|
||||
@Query("SELECT * FROM service WHERE id=:id")
|
||||
fun get(id: Long): Service?
|
||||
|
||||
|
@ -45,11 +35,6 @@ interface ServiceDao {
|
|||
fun deleteExceptAccounts(accountNames: Array<String>)
|
||||
|
||||
@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
|
||||
)
|
||||
}
|
|
@ -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<List<SyncStats>>
|
||||
fun getByCollectionIdFlow(id: Long): Flow<List<SyncStats>>
|
||||
|
||||
}
|
|
@ -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<Account> = accountManager.getAccountsByType(accountType)
|
||||
|
||||
fun getAllFlow() = callbackFlow<Set<Account>> {
|
||||
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
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
|
||||
}
|
|
@ -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)
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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<List<LastSynced>> =
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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 }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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<Set<Account>> {
|
||||
val listener = OnAccountsUpdateListener { accounts ->
|
||||
trySend(accounts.filter { it.type == accountType }.toSet())
|
||||
}
|
||||
accountManager.addOnAccountsUpdatedListener(listener, null, true)
|
||||
|
||||
awaitClose {
|
||||
accountManager.removeOnAccountsUpdatedListener(listener)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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<Boolean> {
|
||||
val workQuery = WorkQuery.Builder
|
||||
.fromStates(workStates)
|
||||
): Flow<Boolean> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
)
|
||||
))
|
||||
}
|
||||
|
|
|
@ -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<AccountModel> {
|
||||
val account = intent.getParcelableExtra(EXTRA_ACCOUNT) as? Account
|
||||
?: throw IllegalArgumentException("AccountActivity requires EXTRA_ACCOUNT")
|
||||
object: ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T: ViewModel> create(modelClass: Class<T>) =
|
||||
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<Collection>?,
|
||||
hasCalDav: Boolean,
|
||||
canCreateCalendar: Boolean,
|
||||
calDavProgress: AccountsModel.Progress,
|
||||
calDavRefreshing: Boolean,
|
||||
calendars: LazyPagingItems<Collection>?,
|
||||
subscriptions: LazyPagingItems<Collection>?,
|
||||
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<String>,
|
||||
progress: AccountsModel.Progress,
|
||||
collections: LazyPagingItems<Collection>?,
|
||||
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
|
||||
}
|
|
@ -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<Boolean>()
|
||||
|
||||
private val settings = AccountSettings(context, account)
|
||||
private val refreshSettingsSignal = MutableLiveData(Unit)
|
||||
val showOnlyPersonal = refreshSettingsSignal.switchMap<Unit, AccountSettings.ShowOnlyPersonal> {
|
||||
object : LiveData<AccountSettings.ShowOnlyPersonal>() {
|
||||
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<String>()
|
||||
|
||||
|
||||
init {
|
||||
accountManager.addOnAccountsUpdatedListener(this, null, true)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
accountManager.removeOnAccountsUpdatedListener(this)
|
||||
}
|
||||
|
||||
override fun onAccountsUpdated(accounts: Array<out Account>) {
|
||||
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<Pair<String, Long?>>) {
|
||||
// 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<Optional<Exception>>()
|
||||
/**
|
||||
* 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<Optional<Exception>>()
|
||||
/** 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<String?> {
|
||||
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<Map<String, Long>> {
|
||||
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<String, Long>()
|
||||
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<Service?>,
|
||||
private val collectionType: String,
|
||||
showOnlyPersonal: LiveData<AccountSettings.ShowOnlyPersonal>
|
||||
) : MediatorLiveData<Pager<Int, Collection>?>() {
|
||||
|
||||
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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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<Service?>,
|
||||
authoritiesFlow: Flow<List<String>>
|
||||
): Flow<AccountProgress> {
|
||||
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<Service?>): Flow<Boolean> =
|
||||
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<List<String>>): Flow<Boolean> =
|
||||
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<List<String>>): Flow<Boolean> =
|
||||
authoritiesFlow.flatMapLatest { authorities ->
|
||||
BaseSyncWorker.exists(
|
||||
context = context,
|
||||
workStates = listOf(WorkInfo.State.RUNNING),
|
||||
account = account,
|
||||
authorities = authorities
|
||||
)
|
||||
}
|
||||
|
||||
}
|
|
@ -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<AccountScreenModel>(
|
||||
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<Collection>?,
|
||||
hasCalDav: Boolean,
|
||||
canCreateCalendar: Boolean,
|
||||
calDavProgress: AccountProgress,
|
||||
calendars: LazyPagingItems<Collection>?,
|
||||
hasWebcal: Boolean,
|
||||
subscriptions: LazyPagingItems<Collection>?,
|
||||
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<String>,
|
||||
progress: AccountProgress,
|
||||
collections: LazyPagingItems<Collection>?,
|
||||
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))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
|
@ -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 <T : ViewModel> create(modelClass: Class<T>): 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<Unit, AccountSettings.ShowOnlyPersonal> {
|
||||
object : LiveData<AccountSettings.ShowOnlyPersonal>() {
|
||||
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<AccountProgress> = 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<String?>(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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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<Account>(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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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<String, Long>
|
||||
) {
|
||||
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()
|
||||
)
|
||||
}
|
|
@ -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<CollectionScreenModel>(
|
||||
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<DavSyncStatsRepository.LastSynced> = 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"
|
||||
)
|
||||
}
|
|
@ -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 <T : ViewModel> create(modelClass: Class<T>): 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<String?> = 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<Exception?>(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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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<Collection>,
|
||||
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 = {}
|
||||
)
|
||||
}
|
|
@ -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<Account>(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set") }
|
||||
|
||||
@Inject
|
||||
lateinit var modelFactory: AccountModel.Factory
|
||||
val model by viewModels<AccountModel> {
|
||||
object: ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
modelFactory.create(account) as T
|
||||
}
|
||||
val account by lazy {
|
||||
intent.getParcelableExtra<Account>(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<HomeSet?>(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<HomeSet>,
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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 <T : ViewModel> create(modelClass: Class<T>): 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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<CreateAddressBookModel>(
|
||||
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<HomeSet>,
|
||||
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())
|
||||
)
|
||||
)
|
||||
}
|
|
@ -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<Account>(EXTRA_ACCOUNT) ?: throw IllegalArgumentException("EXTRA_ACCOUNT must be set") }
|
||||
|
||||
@Inject
|
||||
lateinit var modelFactory: AccountModel.Factory
|
||||
val accountModel by viewModels<AccountModel> {
|
||||
object: ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
modelFactory.create(account) as T
|
||||
}
|
||||
val account by lazy {
|
||||
intent.getParcelableExtra<Account>(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<HomeSet?>(null) }
|
||||
var timeZoneId by remember { mutableStateOf<String>(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<HomeSet>,
|
||||
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 <display name, ID> pairs.
|
||||
*/
|
||||
val timeZoneDefs = MutableLiveData<List<Pair<String, String>>>()
|
||||
|
||||
init {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val timeZones = mutableListOf<Pair<String, String>>()
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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 <T : ViewModel> create(modelClass: Class<T>): 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 <display name, ID> pairs. */
|
||||
val timeZones: Flow<List<TimeZoneInfo>> = flow {
|
||||
val timeZones = mutableListOf<TimeZoneInfo>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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<CreateCalendarModel>(
|
||||
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<CreateCalendarModel.TimeZoneInfo>,
|
||||
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<HomeSet>,
|
||||
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()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
|
@ -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<HomeSet>,
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
)
|
||||
}
|
|
@ -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<Service?>): Flow<List<HomeSet>> =
|
||||
serviceFlow.flatMapLatest { service ->
|
||||
if (service == null)
|
||||
flowOf(emptyList())
|
||||
else
|
||||
homeSetDao.getBindableByServiceFlow(service.id)
|
||||
}
|
||||
|
||||
}
|
|
@ -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<Service?>,
|
||||
collectionType: String,
|
||||
showOnlyPersonalFlow: Flow<AccountSettings.ShowOnlyPersonal?>
|
||||
): Flow<Pager<Int, Collection>?> =
|
||||
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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
}
|
|
@ -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<HomeSet>,
|
||||
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 = {}
|
||||
)
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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 = {}
|
||||
)
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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() {
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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() {
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
}
|
10
app/src/main/kotlin/at/bitfire/davdroid/util/UrlUtils.kt
Normal file
10
app/src/main/kotlin/at/bitfire/davdroid/util/UrlUtils.kt
Normal file
|
@ -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() } ?: "/"
|
|
@ -334,7 +334,7 @@
|
|||
<!--collection management-->
|
||||
<string name="create_addressbook">Crea una llibreta d\'adreces</string>
|
||||
<string name="create_calendar">Crea un calendari</string>
|
||||
<string name="create_calendar_time_zone">Zona horària predeterminada</string>
|
||||
<string name="create_calendar_time_zone_optional">Zona horària predeterminada</string>
|
||||
<string name="create_calendar_type">Possibles entrades de calendari</string>
|
||||
<string name="create_calendar_type_vevent">Esdeveniments</string>
|
||||
<string name="create_calendar_type_vtodo">Tasques</string>
|
||||
|
|
|
@ -320,7 +320,7 @@
|
|||
<!--collection management-->
|
||||
<string name="create_addressbook">Adressbuch erstellen</string>
|
||||
<string name="create_calendar">Kalender anlegen</string>
|
||||
<string name="create_calendar_time_zone">Standardzeitzone</string>
|
||||
<string name="create_calendar_time_zone_optional">Standardzeitzone</string>
|
||||
<string name="create_calendar_type">Mögliche Kalendereinträge</string>
|
||||
<string name="create_calendar_type_vevent">Termine</string>
|
||||
<string name="create_calendar_type_vtodo">Aufgaben</string>
|
||||
|
|
|
@ -333,7 +333,7 @@
|
|||
<!--collection management-->
|
||||
<string name="create_addressbook">Sortu helbide liburua</string>
|
||||
<string name="create_calendar">Sortu egutegia</string>
|
||||
<string name="create_calendar_time_zone">Ordu-zona lehenetsia</string>
|
||||
<string name="create_calendar_time_zone_optional">Ordu-zona lehenetsia</string>
|
||||
<string name="create_calendar_type">Egutegi sarrera posibleak</string>
|
||||
<string name="create_calendar_type_vevent">Gertaerak</string>
|
||||
<string name="create_calendar_type_vtodo">Zereginak</string>
|
||||
|
|
|
@ -335,7 +335,7 @@
|
|||
<!--collection management-->
|
||||
<string name="create_addressbook">Crear libreta de enderezos</string>
|
||||
<string name="create_calendar">Crear calendario</string>
|
||||
<string name="create_calendar_time_zone">Zona horaria por defecto</string>
|
||||
<string name="create_calendar_time_zone_optional">Zona horaria por defecto</string>
|
||||
<string name="create_calendar_type">Entradas posibles no calendario</string>
|
||||
<string name="create_calendar_type_vevent">Eventos</string>
|
||||
<string name="create_calendar_type_vtodo">Tarefas</string>
|
||||
|
|
|
@ -331,7 +331,7 @@
|
|||
<!--collection management-->
|
||||
<string name="create_addressbook">アドレス帳を作成</string>
|
||||
<string name="create_calendar">カレンダーを作成</string>
|
||||
<string name="create_calendar_time_zone">デフォルトのタイムゾーン</string>
|
||||
<string name="create_calendar_time_zone_optional">デフォルトのタイムゾーン</string>
|
||||
<string name="create_calendar_type">可能なカレンダーエントリー</string>
|
||||
<string name="create_calendar_type_vevent">予定</string>
|
||||
<string name="create_calendar_type_vtodo">タスク</string>
|
||||
|
|
|
@ -159,7 +159,7 @@
|
|||
<string name="create_addressbook">Opprett adressebok</string>
|
||||
<string name="create_addressbook_display_name_hint">Min adressebok</string>
|
||||
<string name="create_calendar">Opprett kalender</string>
|
||||
<string name="create_calendar_time_zone">Tidssone</string>
|
||||
<string name="create_calendar_time_zone_optional">Tidssone</string>
|
||||
<string name="create_calendar_type">Mulige kalenderhendelser</string>
|
||||
<string name="create_calendar_type_vevent">Hendelser</string>
|
||||
<string name="create_calendar_type_vtodo">Oppgaver</string>
|
||||
|
|
|
@ -334,7 +334,7 @@
|
|||
<!--collection management-->
|
||||
<string name="create_addressbook">Adresboek aanmaken</string>
|
||||
<string name="create_calendar">Kalender aanmaken</string>
|
||||
<string name="create_calendar_time_zone">Standaard tijdzone</string>
|
||||
<string name="create_calendar_time_zone_optional">Standaard tijdzone</string>
|
||||
<string name="create_calendar_type">Mogelijke kalender-items</string>
|
||||
<string name="create_calendar_type_vevent">Gebeurtenissen</string>
|
||||
<string name="create_calendar_type_vtodo">Taken</string>
|
||||
|
|
|
@ -336,7 +336,7 @@
|
|||
<!--collection management-->
|
||||
<string name="create_addressbook">Creează agendă de adrese</string>
|
||||
<string name="create_calendar">Creează un calendar</string>
|
||||
<string name="create_calendar_time_zone">Fus orar implicit</string>
|
||||
<string name="create_calendar_time_zone_optional">Fus orar implicit</string>
|
||||
<string name="create_calendar_type">Posibile intrări din calendar</string>
|
||||
<string name="create_calendar_type_vevent">Evenimente</string>
|
||||
<string name="create_calendar_type_vtodo">Sarcini</string>
|
||||
|
|
|
@ -338,7 +338,7 @@
|
|||
<!--collection management-->
|
||||
<string name="create_addressbook">Создать адресную книгу</string>
|
||||
<string name="create_calendar">Создать календарь</string>
|
||||
<string name="create_calendar_time_zone">Часовой пояс по умолчанию</string>
|
||||
<string name="create_calendar_time_zone_optional">Часовой пояс по умолчанию</string>
|
||||
<string name="create_calendar_type">Возможные записи календаря</string>
|
||||
<string name="create_calendar_type_vevent">События</string>
|
||||
<string name="create_calendar_type_vtodo">Задачи</string>
|
||||
|
|
|
@ -167,7 +167,7 @@
|
|||
<string name="create_addressbook">Ustvari imenik</string>
|
||||
<string name="create_addressbook_display_name_hint">Moj imenik</string>
|
||||
<string name="create_calendar">Ustvari koledar</string>
|
||||
<string name="create_calendar_time_zone">Časovna zona</string>
|
||||
<string name="create_calendar_time_zone_optional">Časovna zona</string>
|
||||
<string name="create_calendar_type">Mogoči koledarski vnosi</string>
|
||||
<string name="create_calendar_type_vevent">Dogodki</string>
|
||||
<string name="create_calendar_type_vtodo">Naloge</string>
|
||||
|
|
|
@ -332,7 +332,7 @@
|
|||
<!--collection management-->
|
||||
<string name="create_addressbook">创建通讯录</string>
|
||||
<string name="create_calendar">创建日历</string>
|
||||
<string name="create_calendar_time_zone">默认时区</string>
|
||||
<string name="create_calendar_time_zone_optional">默认时区</string>
|
||||
<string name="create_calendar_type">可能使用的日历类型</string>
|
||||
<string name="create_calendar_type_vevent">事件</string>
|
||||
<string name="create_calendar_type_vtodo">任务</string>
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
<string name="account_title_address_book">DAVx⁵ Address book</string>
|
||||
<string name="address_books_authority" translatable="false">at.bitfire.davdroid.addressbooks</string>
|
||||
<string name="address_books_authority_title">Address books</string>
|
||||
<string name="dialog_delete">Delete</string>
|
||||
<string name="dialog_remove">Remove</string>
|
||||
<string name="dialog_deny">Cancel</string>
|
||||
<string name="field_required">This field is required</string>
|
||||
|
@ -116,7 +117,7 @@
|
|||
<string name="logging_notification_view_share">View/share</string>
|
||||
<string name="logging_notification_disable">Disable</string>
|
||||
|
||||
<!-- AccountsActivity -->
|
||||
<!-- AccountsScreen -->
|
||||
<string name="navigation_drawer_subtitle">CalDAV/CardDAV Sync Adapter</string>
|
||||
<string name="navigation_drawer_about">About / License</string>
|
||||
<string name="navigation_drawer_beta_feedback">Beta feedback</string>
|
||||
|
@ -209,7 +210,7 @@
|
|||
<string name="app_settings_tasks_provider">Tasks app</string>
|
||||
<string name="app_settings_tasks_provider_none">No compatible tasks app found</string>
|
||||
|
||||
<!-- AccountActivity -->
|
||||
<!-- AccountScreen -->
|
||||
<string name="account_carddav">CardDAV</string>
|
||||
<string name="account_caldav">CalDAV</string>
|
||||
<string name="account_webcal">Webcal</string>
|
||||
|
@ -229,12 +230,12 @@
|
|||
<string name="account_synchronize_this_collection">synchronize this collection</string>
|
||||
<string name="account_read_only">read-only</string>
|
||||
<string name="account_calendar">calendar</string>
|
||||
<string name="account_task_list">task list</string>
|
||||
<string name="account_contacts">contacts</string>
|
||||
<string name="account_journal">journal</string>
|
||||
<string name="account_task_list">tasks</string>
|
||||
<string name="account_only_personal">Show only personal</string>
|
||||
<string name="account_refresh_collections">Refresh collections</string>
|
||||
<string name="account_refreshing_collections">Refreshing collection list</string>
|
||||
<string name="account_webcal_external_app">"Webcal subscriptions can be synchronized with external apps."</string>
|
||||
<string name="account_refresh_collections">Refresh list</string>
|
||||
<string name="account_webcal_external_app">Webcal subscriptions can be synchronized with external apps.</string>
|
||||
<string name="account_no_webcal_handler_found">No Webcal-capable app found</string>
|
||||
<string name="account_install_icsx5">Install ICSx⁵</string>
|
||||
|
||||
|
@ -376,38 +377,41 @@
|
|||
<item>Groups are per-contact categories</item>
|
||||
</string-array>
|
||||
|
||||
<!-- collection management -->
|
||||
<!-- CreateAddressBookScreen, CreateCalendarScreen -->
|
||||
<string name="create_addressbook">Create address book</string>
|
||||
<string name="create_addressbook_maybe_not_supported">Address book creation over CardDAV may not be supported by the server.</string>
|
||||
<string name="create_calendar">Create calendar</string>
|
||||
<string name="create_calendar_time_zone">Default time zone</string>
|
||||
<string name="create_calendar_time_zone_optional">Default time zone*</string>
|
||||
<string name="create_calendar_time_zone_none">—</string>
|
||||
<string name="create_calendar_type">Possible calendar entries</string>
|
||||
<string name="create_calendar_type_vevent">Events</string>
|
||||
<string name="create_calendar_type_vtodo">Tasks</string>
|
||||
<string name="create_calendar_type_vjournal">Notes / journal</string>
|
||||
<string name="create_calendar_maybe_not_supported">Calendar creation over CalDAV may not be supported by the server.</string>
|
||||
<string name="create_collection_color">Color</string>
|
||||
<string name="create_collection_creating">Creating collection</string>
|
||||
<string name="create_collection_display_name">Title</string>
|
||||
<string name="create_collection_display_name_required">Title is required</string>
|
||||
<string name="create_collection_description">Description</string>
|
||||
<string name="create_collection_description_optional">Description (optional)</string>
|
||||
<string name="create_collection_optional">optional</string>
|
||||
<string name="create_collection_home_set">Storage location</string>
|
||||
<string name="create_collection_home_set_required">Storage location is required</string>
|
||||
<string name="create_collection_description_optional">Description*</string>
|
||||
<string name="create_collection_create">Create</string>
|
||||
<string name="delete_collection">Delete collection</string>
|
||||
<string name="delete_collection_confirm_title">Are you sure?</string>
|
||||
<string name="delete_collection_confirm_warning">This collection (%s) and all its data will be removed permanently.</string>
|
||||
<string name="delete_collection_data_shall_be_deleted">These data shall be deleted from the server.</string>
|
||||
<string name="collection_force_read_only">Force read-only</string>
|
||||
<string name="collection_properties">Properties</string>
|
||||
<string name="collection_properties_sync_time">Last synced</string>
|
||||
<string name="collection_properties_sync_time_never">Never synced</string>
|
||||
<string name="collection_properties_url">Address (URL)</string>
|
||||
<string name="collection_properties_owner">Owner</string>
|
||||
<string name="collection_properties_push_support">Push support</string>
|
||||
<string name="collection_properties_push_support_web_push">Yes (over Web Push)</string>
|
||||
<string name="collection_properties_push_subscribed_at">Subscribed at %s</string>
|
||||
<string name="collection_properties_push_subscribed_never">Not yet subscribed</string>
|
||||
<string name="create_collection_optional">* optional</string>
|
||||
|
||||
<!-- CollectionScreen -->
|
||||
<string name="collection_delete">Delete collection</string>
|
||||
<string name="collection_delete_warning">This collection (%s) and all its data will be removed permanently, both locally and on the server.</string>
|
||||
<string name="collection_synchronization">Synchronization</string>
|
||||
<string name="collection_synchronization_on">Synchronization enabled</string>
|
||||
<string name="collection_synchronization_off">Synchronization disabled</string>
|
||||
<string name="collection_read_only">Read-only</string>
|
||||
<string name="collection_read_only_by_server">Read-only (by server)</string>
|
||||
<string name="collection_read_only_forced">Read-only (only locally)</string>
|
||||
<string name="collection_read_write">Read/write</string>
|
||||
<string name="collection_title">Title</string>
|
||||
<string name="collection_description">Description</string>
|
||||
<string name="collection_owner">Owner</string>
|
||||
<string name="collection_push_support">Push support</string>
|
||||
<string name="collection_push_web_push">Server advertises Push support</string>
|
||||
<string name="collection_last_sync">Last sync (%s)</string>
|
||||
<string name="collection_url">Address (URL)</string>
|
||||
|
||||
<!-- debugging and DebugInfoActivity -->
|
||||
<string name="authority_debug_provider" translatable="false">at.bitfire.davdroid.debug</string>
|
||||
|
|
Loading…
Reference in a new issue