Rewrite AccountsActivity to Compose (bitfireAT/davx5#431)

---------

Signed-off-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Arnau Mora <arnyminerz@proton.me>
Co-authored-by: Sunik Kupfer <kupfer@bitfire.at>
This commit is contained in:
Ricki Hirner 2023-11-07 17:16:27 +01:00
parent 9d739dd087
commit 518e5147fe
No known key found for this signature in database
GPG key ID: 79A019FCAAEDD3AA
22 changed files with 606 additions and 730 deletions

View file

@ -158,7 +158,8 @@ dependencies {
implementation 'androidx.compose.runtime:runtime-livedata'
debugImplementation 'androidx.compose.ui:ui-tooling'
implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'com.google.accompanist:accompanist-themeadapter-material:0.30.1'
implementation "com.google.accompanist:accompanist-permissions:${versions.accompanist}"
implementation "com.google.accompanist:accompanist-themeadapter-material:${versions.accompanist}"
// Jetpack Room
implementation "androidx.room:room-runtime:${versions.room}"

View file

@ -128,14 +128,14 @@ class RefreshCollectionsWorkerTest {
@Test
fun testRefreshCollections_enqueuesWorker() {
val service = createTestService(Service.TYPE_CALDAV)!!
val workerName = RefreshCollectionsWorker.refreshCollections(context, service.id)
val workerName = RefreshCollectionsWorker.enqueue(context, service.id)
assertTrue(workScheduledOrRunning(context, workerName))
}
@Test
fun testOnStopped_stopsRefreshThread() {
val service = createTestService(Service.TYPE_CALDAV)!!
val workerName = RefreshCollectionsWorker.refreshCollections(context, service.id)
val workerName = RefreshCollectionsWorker.enqueue(context, service.id)
WorkManager.getInstance(context).cancelUniqueWork(workerName)
assertFalse(workScheduledOrRunning(context, workerName))

View file

@ -91,7 +91,7 @@ abstract class AppDatabase: RoomDatabase() {
db.query("SELECT id FROM service", arrayOf()).use { cursor ->
while (cursor.moveToNext()) {
val serviceId = cursor.getLong(0)
RefreshCollectionsWorker.refreshCollections(context, serviceId)
RefreshCollectionsWorker.enqueue(context, serviceId)
}
}
}

View file

@ -17,6 +17,9 @@ interface ServiceDao {
@Query("SELECT * FROM service WHERE accountName=:accountName AND type=:type")
fun getByAccountAndType(accountName: String, type: String): Service?
@Query("SELECT id FROM service WHERE accountName=:accountName")
fun getIdsByAccount(accountName: String): List<Long>
@Query("SELECT id FROM service WHERE accountName=:accountName AND type=:type")
fun getIdByAccountAndType(accountName: String, type: String): LiveData<Long>

View file

@ -98,7 +98,7 @@ class RefreshCollectionsWorker @AssistedInject constructor(
companion object {
const val ARG_SERVICE_ID = "serviceId"
const val REFRESH_COLLECTIONS_WORKER_TAG = "refreshCollectionsWorker"
const val WORKER_TAG = "refreshCollectionsWorker"
// Collection properties to ask for in a propfind request to the Cal- or CardDAV server
val DAV_COLLECTION_PROPERTIES = arrayOf(
@ -122,7 +122,7 @@ class RefreshCollectionsWorker @AssistedInject constructor(
*
* @param serviceId what service (CalDAV/CardDAV) the worker is running for
*/
fun workerName(serviceId: Long): String = "$REFRESH_COLLECTIONS_WORKER_TAG-$serviceId"
fun workerName(serviceId: Long): String = "$WORKER_TAG-$serviceId"
/**
* Requests immediate refresh of a given service. If not running already. this will enqueue
@ -133,24 +133,23 @@ class RefreshCollectionsWorker @AssistedInject constructor(
*
* @throws IllegalArgumentException when there's no service with this ID
*/
fun refreshCollections(context: Context, serviceId: Long): String {
if (serviceId == -1L)
throw IllegalArgumentException("Service with ID \"$serviceId\" does not exist")
fun enqueue(context: Context, serviceId: Long): String {
val name = workerName(serviceId)
val arguments = Data.Builder()
.putLong(ARG_SERVICE_ID, serviceId)
.build()
val workRequest = OneTimeWorkRequestBuilder<RefreshCollectionsWorker>()
.addTag(name)
.setInputData(arguments)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
workerName(serviceId),
name,
ExistingWorkPolicy.KEEP, // if refresh is already running, just continue that one
workRequest
)
return workerName(serviceId)
return name
}
/**
@ -160,7 +159,7 @@ class RefreshCollectionsWorker @AssistedInject constructor(
* @param workState state of worker to match
* @return boolean true if worker with matching state was found
*/
fun isWorkerInState(context: Context, workerName: String, workState: WorkInfo.State) =
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 }
}

View file

@ -1,339 +0,0 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.ui
import android.Manifest
import android.accounts.Account
import android.accounts.AccountManager
import android.accounts.OnAccountsUpdateListener
import android.app.Activity
import android.app.Application
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.annotation.AnyThread
import androidx.core.content.ContextCompat
import androidx.core.view.MenuHost
import androidx.core.view.MenuProvider
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkQuery
import at.bitfire.davdroid.R
import at.bitfire.davdroid.databinding.AccountListBinding
import at.bitfire.davdroid.databinding.AccountListItemBinding
import at.bitfire.davdroid.syncadapter.SyncUtils.syncAuthorities
import at.bitfire.davdroid.syncadapter.SyncWorker
import at.bitfire.davdroid.ui.account.AccountActivity
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import java.text.Collator
import javax.inject.Inject
@AndroidEntryPoint
class AccountListFragment: Fragment() {
private var _binding: AccountListBinding? = null
private val binding get() = _binding!!
val model by viewModels<Model>()
private var syncStatusSnackbar: Snackbar? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = AccountListBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.allowNotifications.setOnClickListener {
startActivity(Intent(requireActivity(), PermissionsActivity::class.java))
}
model.globalSyncDisabled.observe(viewLifecycleOwner) { syncDisabled ->
if (syncDisabled) {
val snackbar = Snackbar
.make(view, R.string.accounts_global_sync_disabled, Snackbar.LENGTH_INDEFINITE)
.setAction(R.string.accounts_global_sync_enable) {
ContentResolver.setMasterSyncAutomatically(true)
}
snackbar.show()
syncStatusSnackbar = snackbar
} else {
syncStatusSnackbar?.let { snackbar ->
snackbar.dismiss()
syncStatusSnackbar = null
}
}
}
model.networkAvailable.observe(viewLifecycleOwner) { networkAvailable ->
binding.noNetworkInfo.visibility = if (networkAvailable) View.GONE else View.VISIBLE
}
binding.manageConnections.setOnClickListener {
val intent = Intent(Settings.ACTION_WIRELESS_SETTINGS)
if (intent.resolveActivity(requireActivity().packageManager) != null)
startActivity(intent)
}
model.storageLow.observe(viewLifecycleOwner) { storageLow ->
binding.lowStorageInfo.visibility = if (storageLow) View.VISIBLE else View.GONE
}
binding.manageStorage.setOnClickListener {
val intent = Intent(Settings.ACTION_INTERNAL_STORAGE_SETTINGS)
if (intent.resolveActivity(requireActivity().packageManager) != null)
startActivity(intent)
}
model.dataSaverOn.observe(viewLifecycleOwner) { datasaverOn ->
binding.datasaverOnInfo.visibility = if (datasaverOn) View.VISIBLE else View.GONE
}
binding.manageDatasaver.setOnClickListener {
val intent = Intent(Settings.ACTION_IGNORE_BACKGROUND_DATA_RESTRICTIONS_SETTINGS, Uri.parse("package:" + requireActivity().packageName))
if (intent.resolveActivity(requireActivity().packageManager) != null)
startActivity(intent)
}
// Accounts adapter
val accountAdapter = AccountAdapter(requireActivity())
binding.list.apply {
layoutManager = LinearLayoutManager(requireActivity())
adapter = accountAdapter
}
model.accounts.observe(viewLifecycleOwner) { accounts ->
if (accounts.isEmpty()) {
binding.list.visibility = View.GONE
binding.empty.visibility = View.VISIBLE
} else {
binding.list.visibility = View.VISIBLE
binding.empty.visibility = View.GONE
}
accountAdapter.submitList(accounts)
requireActivity().invalidateOptionsMenu()
}
requireActivity().addMenuProvider(object : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.activity_accounts, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem) =
when (menuItem.itemId) {
R.id.syncAll -> {
(activity as AccountsActivity).syncAllAccounts()
true
}
else -> false
}
override fun onPrepareMenu(menu: Menu) {
// Show "Sync all" only when there is at least one account
model.accounts.value?.let { accounts ->
menu.findItem(R.id.syncAll).setVisible(accounts.isNotEmpty())
}
}
})
}
override fun onResume() {
super.onResume()
checkPermissions()
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
fun checkPermissions() {
if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED)
binding.noNotificationsInfo.visibility = View.GONE
else
binding.noNotificationsInfo.visibility = View.VISIBLE
}
class AccountAdapter(
val activity: Activity
): ListAdapter<Model.AccountInfo, AccountAdapter.ViewHolder>(
object: DiffUtil.ItemCallback<Model.AccountInfo>() {
override fun areItemsTheSame(oldItem: Model.AccountInfo, newItem: Model.AccountInfo) =
oldItem.account == newItem.account
override fun areContentsTheSame(oldItem: Model.AccountInfo, newItem: Model.AccountInfo) =
oldItem == newItem
}
) {
class ViewHolder(val binding: AccountListItemBinding): RecyclerView.ViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = AccountListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ViewHolder(binding)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val accountInfo = currentList[position]
holder.binding.root.setOnClickListener {
val intent = Intent(activity, AccountActivity::class.java)
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, accountInfo.account)
activity.startActivity(intent)
}
when (accountInfo.status) {
SyncStatus.ACTIVE -> {
holder.binding.progress.apply {
alpha = 1.0f
isIndeterminate = true
visibility = View.VISIBLE
}
}
SyncStatus.PENDING -> {
holder.binding.progress.apply {
alpha = 0.4f
isIndeterminate = false
progress = 100
visibility = View.VISIBLE
}
}
else -> holder.binding.progress.visibility = View.INVISIBLE
}
holder.binding.accountName.text = accountInfo.account.name
}
}
@HiltViewModel
class Model @Inject constructor(
application: Application,
private val warnings: AppWarningsManager
): AndroidViewModel(application), OnAccountsUpdateListener {
data class AccountInfo(
val account: Account,
val status: SyncStatus
)
// Warnings
val globalSyncDisabled = warnings.globalSyncDisabled
val dataSaverOn = warnings.dataSaverEnabled
val networkAvailable = warnings.networkAvailable
val storageLow = warnings.storageLow
// Accounts
private val accountsUpdated = MutableLiveData<Boolean>()
private val syncWorkersActive = SyncWorker.exists(
application,
listOf(
WorkInfo.State.RUNNING,
WorkInfo.State.ENQUEUED
)
)
val accounts = object : MediatorLiveData<List<AccountInfo>>() {
init {
addSource(accountsUpdated) { recalculate() }
addSource(syncWorkersActive) { recalculate() }
}
fun recalculate() {
val context = getApplication<Application>()
val collator = Collator.getInstance()
val sortedAccounts = accountManager
.getAccountsByType(context.getString(R.string.account_type))
.sortedArrayWith { a, b ->
collator.compare(a.name, b.name)
}
val accountsWithInfo = sortedAccounts.map { account ->
AccountInfo(
account,
SyncStatus.fromAccount(context, account)
)
}
value = accountsWithInfo
}
}
private val accountManager = AccountManager.get(application)!!
init {
// watch accounts
accountManager.addOnAccountsUpdatedListener(this, null, true)
}
@AnyThread
override fun onAccountsUpdated(newAccounts: Array<out Account>) {
accountsUpdated.postValue(true)
}
override fun onCleared() {
accountManager.removeOnAccountsUpdatedListener(this)
warnings.close()
}
}
enum class SyncStatus {
ACTIVE, PENDING, IDLE;
companion object {
/**
* Returns the sync status of a given account. Checks the account itself and possible
* sub-accounts (address book accounts).
*
* @param account account to check
*
* @return sync status of the given account
*/
fun fromAccount(context: Context, account: Account): SyncStatus {
// Add contacts authority, so sync status of address-book-accounts is also checked
val workerNames = syncAuthorities(context, true).map { authority ->
SyncWorker.workerName(account, authority)
}
val workQuery = WorkQuery.Builder
.fromTags(workerNames)
.addStates(listOf(WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED))
.build()
val workInfos = WorkManager.getInstance(context).getWorkInfos(workQuery).get()
return when {
workInfos.any { workInfo ->
workInfo.state == WorkInfo.State.RUNNING
} -> ACTIVE
workInfos.any {workInfo ->
workInfo.state == WorkInfo.State.ENQUEUED
} -> PENDING
else -> IDLE
}
}
}
}
}

View file

@ -4,52 +4,115 @@
package at.bitfire.davdroid.ui
import android.Manifest
import android.accounts.Account
import android.accounts.AccountManager
import android.app.Activity
import android.accounts.OnAccountsUpdateListener
import android.app.Application
import android.content.Intent
import android.content.pm.ShortcutManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.MenuItem
import androidx.activity.addCallback
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.TooltipCompat
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Card
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.FloatingActionButton
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.IconToggleButton
import androidx.compose.material.LinearProgressIndicator
import androidx.compose.material.MaterialTheme
import androidx.compose.material.ProgressIndicatorDefaults
import androidx.compose.material.Scaffold
import androidx.compose.material.ScaffoldState
import androidx.compose.material.SnackbarHost
import androidx.compose.material.SnackbarHostState
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material.rememberScaffoldState
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.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.getSystemService
import androidx.core.view.GravityCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkQuery
import at.bitfire.davdroid.R
import at.bitfire.davdroid.databinding.ActivityAccountsBinding
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.syncadapter.SyncUtils
import at.bitfire.davdroid.syncadapter.SyncWorker
import at.bitfire.davdroid.ui.account.AccountActivity
import at.bitfire.davdroid.ui.intro.IntroActivity
import at.bitfire.davdroid.ui.setup.LoginActivity
import at.bitfire.davdroid.ui.widget.ActionCard
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.themeadapter.material.MdcTheme
import com.google.android.material.navigation.NavigationView
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.text.Collator
import javax.inject.Inject
@AndroidEntryPoint
class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener {
class AccountsActivity: AppCompatActivity() {
@Inject lateinit var accountsDrawerHandler: AccountsDrawerHandler
private lateinit var binding: ActivityAccountsBinding
val model by viewModels<Model>()
private val model by viewModels<Model>()
private val introActivityLauncher = registerForActivityResult(IntroActivity.Contract) { cancelled ->
if (cancelled) {
if (cancelled)
finish()
}
}
@OptIn(ExperimentalMaterialApi::class, ExperimentalPermissionsApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -61,79 +124,462 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele
}
}
binding = ActivityAccountsBinding.inflate(layoutInflater)
setContentView(binding.root)
setContent {
val scope = rememberCoroutineScope()
val scaffoldState = rememberScaffoldState()
val snackbarHostState = remember { SnackbarHostState() }
TooltipCompat.setTooltipText(binding.content.fab, binding.content.fab.contentDescription)
binding.content.fab.setOnClickListener {
startActivity(Intent(this, LoginActivity::class.java))
}
binding.content.fab.show()
val refreshing by remember { mutableStateOf(false) }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = {
model.syncAllAccounts()
})
setSupportActionBar(binding.content.toolbar)
MdcTheme {
Scaffold(
scaffoldState = scaffoldState,
drawerContent = drawerContent(scope, scaffoldState),
topBar = topBar(scope, scaffoldState),
floatingActionButton = floatingActionButton(),
snackbarHost = snackbarHost(snackbarHostState, scope)
) { padding ->
Box(
Modifier
.padding(padding)
.pullRefresh(pullRefreshState)
.verticalScroll(rememberScrollState())
) {
val toggle = ActionBarDrawerToggle(
this, binding.drawerLayout, binding.content.toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close)
binding.drawerLayout.addDrawerListener(toggle)
toggle.syncState()
// background image
Image(
painterResource(R.drawable.accounts_background),
contentDescription = null,
modifier = Modifier.fillMaxSize()
)
binding.navView.setNavigationItemSelectedListener(this)
binding.navView.itemIconTintList = null
Column {
val warnings = model.warnings
onBackPressedDispatcher.addCallback(this) {
if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) {
binding.drawerLayout.closeDrawer(GravityCompat.START)
} else {
finish()
val notificationsPermissionState = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
rememberPermissionState(
permission = Manifest.permission.POST_NOTIFICATIONS
)
} else {
null
}
// Warnings show as action cards
SyncWarnings(
notificationsWarning = notificationsPermissionState?.status?.isGranted == false,
onClickPermissions = {
startActivity(Intent(this@AccountsActivity, PermissionsActivity::class.java))
},
internetWarning = warnings.networkAvailable.observeAsState().value == false,
onManageConnections = {
val intent = Intent(android.provider.Settings.ACTION_WIRELESS_SETTINGS)
if (intent.resolveActivity(packageManager) != null)
startActivity(intent)
},
lowStorageWarning = warnings.storageLow.observeAsState().value == true,
onManageStorage = {
val intent = Intent(android.provider.Settings.ACTION_INTERNAL_STORAGE_SETTINGS)
if (intent.resolveActivity(packageManager) != null)
startActivity(intent)
},
dataSaverActive = warnings.dataSaverEnabled.observeAsState().value == true,
onManageDataSaver = {
val intent = Intent(android.provider.Settings.ACTION_IGNORE_BACKGROUND_DATA_RESTRICTIONS_SETTINGS, Uri.parse("package:" + packageName))
if (intent.resolveActivity(packageManager) != null)
startActivity(intent)
}
)
// account list
val accounts = model.accountInfos.observeAsState()
AccountList(
accounts = accounts.value ?: emptyList(),
onClickAccount = { account ->
val activity = this@AccountsActivity
val intent = Intent(activity, AccountActivity::class.java)
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
activity.startActivity(intent)
},
modifier = Modifier
.fillMaxSize()
.padding(8.dp)
)
}
// indicate when the user pulls down
PullRefreshIndicator(refreshing, pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter))
}
}
}
BackHandler {
scope.launch {
if (scaffoldState.drawerState.isOpen)
scaffoldState.drawerState.close()
else
finish()
}
}
}
// handle "Sync all" intent from launcher shortcut
if (savedInstanceState == null && intent.action == Intent.ACTION_SYNC)
syncAllAccounts()
model.syncAllAccounts()
}
override fun onResume() {
super.onResume()
accountsDrawerHandler.initMenu(this, binding.navView.menu)
@Composable
private fun snackbarHost(
snackbarHostState: SnackbarHostState,
scope: CoroutineScope
): @Composable (SnackbarHostState) -> Unit = {
SnackbarHost(snackbarHostState)
model.feedback.observeAsState().value?.let { msg ->
scope.launch {
snackbarHostState.showSnackbar(msg)
}
// reset feedback
model.feedback.value = null
}
}
override fun onNavigationItemSelected(item: MenuItem): Boolean {
accountsDrawerHandler.onNavigationItemSelected(this, item)
binding.drawerLayout.closeDrawer(GravityCompat.START)
return true
@Composable
private fun floatingActionButton(): @Composable (() -> Unit) = {
val show by model.showAddAccount.observeAsState()
if (show == true)
FloatingActionButton(onClick = {
startActivity(Intent(this@AccountsActivity, LoginActivity::class.java))
}) {
Icon(
Icons.Filled.Add,
stringResource(R.string.login_create_account)
)
}
}
@Composable
private fun topBar(
scope: CoroutineScope,
scaffoldState: ScaffoldState
): @Composable (() -> Unit) = {
TopAppBar(
navigationIcon = {
IconToggleButton(false, onCheckedChange = { openDrawer ->
scope.launch {
if (openDrawer)
scaffoldState.drawerState.open()
else
scaffoldState.drawerState.close()
}
}) {
Icon(
Icons.Filled.Menu,
stringResource(androidx.compose.ui.R.string.navigation_menu)
)
}
},
title = {
Text(stringResource(R.string.app_name))
},
actions = {
IconButton(onClick = { model.syncAllAccounts() }) {
Icon(
painterResource(R.drawable.ic_sync),
contentDescription = stringResource(R.string.accounts_sync_all)
)
}
}
)
}
private fun allAccounts() =
AccountManager.get(this).getAccountsByType(getString(R.string.account_type))
@Composable
private fun drawerContent(
scope: CoroutineScope,
scaffoldState: ScaffoldState
): @Composable (ColumnScope.() -> Unit) =
{
AndroidView(factory = { context ->
// use legacy NavigationView for now
NavigationView(context).apply {
inflateHeaderView(R.layout.nav_header_accounts)
fun syncAllAccounts() {
if (Build.VERSION.SDK_INT >= 25)
getSystemService<ShortcutManager>()?.reportShortcutUsed(UiUtils.SHORTCUT_SYNC_ALL)
inflateMenu(R.menu.activity_accounts_drawer)
accountsDrawerHandler.initMenu(this@AccountsActivity, menu)
// Notify user that sync will get enqueued if we're not connected to the internet
model.networkAvailable.value?.let { networkAvailable ->
if (!networkAvailable)
Snackbar.make(binding.drawerLayout, R.string.no_internet_sync_scheduled, Snackbar.LENGTH_LONG).show()
setNavigationItemSelectedListener { item ->
scope.launch {
accountsDrawerHandler.onNavigationItemSelected(
this@AccountsActivity,
item
)
scaffoldState.drawerState.close()
}
true
}
}
}, modifier = Modifier.fillMaxWidth())
}
// Enqueue sync worker for all accounts and authorities. Will sync once internet is available
val accounts = allAccounts()
for (account in accounts)
SyncWorker.enqueueAllAuthorities(this, account)
}
data class AccountInfo(
val account: Account,
val isRefreshing: Boolean,
val isSyncing: Boolean
)
@HiltViewModel
class Model @Inject constructor(
application: Application,
val settings: SettingsManager,
warnings: AppWarningsManager
): AndroidViewModel(application) {
val db: AppDatabase,
val warnings: AppWarningsManager
): AndroidViewModel(application), OnAccountsUpdateListener {
val feedback = MutableLiveData<String>()
val accountManager = AccountManager.get(application)
private val accountType = application.getString(R.string.account_type)
val showAddAccount = MutableLiveData<Boolean>(true)
val workManager = WorkManager.getInstance(application)
val runningWorkers = workManager.getWorkInfosLiveData(WorkQuery.fromStates(WorkInfo.State.RUNNING))
val accounts = MutableLiveData<Set<Account>>()
val accountInfos = object: MediatorLiveData<List<AccountInfo>>() {
var myAccounts: Set<Account> = emptySet()
var workInfos: List<WorkInfo> = emptyList()
init {
addSource(accounts) { newAccounts ->
myAccounts = newAccounts
update()
}
addSource(runningWorkers) { newWorkInfos ->
workInfos = newWorkInfos
update()
}
}
fun update() = viewModelScope.launch(Dispatchers.Default) {
val authorities = SyncUtils.syncAuthorities(application, withContacts = true)
val collator = Collator.getInstance()
postValue(myAccounts
.toList()
.sortedWith { a, b -> collator.compare(a.name, b.name) }
.map { account ->
val services = db.serviceDao().getIdsByAccount(account.name)
AccountInfo(
account = account,
isRefreshing = workInfos.any { info ->
services.any { serviceId ->
info.tags.contains(RefreshCollectionsWorker.workerName(serviceId))
}
},
isSyncing = workInfos.any { info ->
authorities.any { authority ->
info.tags.contains(SyncWorker.workerName(account, authority))
}
}
)
})
}
}
val networkAvailable = warnings.networkAvailable
init {
accountManager.addOnAccountsUpdatedListener(this, null, true)
}
// callbacks
override fun onAccountsUpdated(newAccounts: Array<out Account>) {
accounts.postValue(newAccounts.filter { it.type == accountType }.toSet())
}
// actions
fun syncAllAccounts() {
val context = getApplication<Application>()
if (Build.VERSION.SDK_INT >= 25)
context.getSystemService<ShortcutManager>()?.reportShortcutUsed(UiUtils.SHORTCUT_SYNC_ALL)
feedback.value = context.getString(
if (networkAvailable.value == false)
R.string.no_internet_sync_scheduled
else
R.string.sync_started
)
// Enqueue sync worker for all accounts and authorities. Will sync once internet is available
for (account in allAccounts())
SyncWorker.enqueueAllAuthorities(context, account)
}
// helpers
private fun allAccounts() =
AccountManager.get(getApplication()).getAccountsByType(accountType)
}
}
@Composable
fun AccountList(
accounts: List<AccountsActivity.AccountInfo>,
modifier: Modifier = Modifier,
onClickAccount: (Account) -> Unit = {}
) {
Column(modifier) {
if (accounts.isEmpty())
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxSize()
.padding(8.dp)
) {
Text(
text = stringResource(R.string.account_list_empty),
style = MaterialTheme.typography.h6,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
else
for (account in accounts)
Card(
backgroundColor = MaterialTheme.colors.secondaryVariant,
contentColor = MaterialTheme.colors.onSecondary,
modifier = Modifier
.clickable { onClickAccount(account.account) }
.fillMaxWidth()
.padding(8.dp)
) {
Column {
if (account.isRefreshing || account.isSyncing)
LinearProgressIndicator(
color = MaterialTheme.colors.onSecondary,
modifier = Modifier.fillMaxWidth()
)
else
Spacer(Modifier.height(ProgressIndicatorDefaults.StrokeWidth))
Column(Modifier.padding(8.dp)) {
Icon(
imageVector = Icons.Default.AccountCircle,
contentDescription = null,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.size(48.dp)
)
Text(
text = account.account.name,
style = MaterialTheme.typography.h5,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(top = 8.dp)
.fillMaxWidth()
)
}
}
}
}
}
@Composable
@Preview
fun AccountList_Preview_Idle() {
AccountList(listOf(
AccountsActivity.AccountInfo(
Account("Account Name", "test"),
isRefreshing = false,
isSyncing = false
)
))
}
@Composable
@Preview
fun AccountList_Preview_IsSyncing() {
AccountList(listOf(
AccountsActivity.AccountInfo(
Account("Account Name", "test"),
isRefreshing = false,
isSyncing = true
)
))
}
@Composable
@Preview
fun AccountList_Preview_Empty() {
AccountList(listOf())
}
@Composable
fun SyncWarnings(
notificationsWarning: Boolean,
onClickPermissions: () -> Unit = {},
internetWarning: Boolean,
onManageConnections: () -> Unit = {},
lowStorageWarning: Boolean,
onManageStorage: () -> Unit = {},
dataSaverActive: Boolean,
onManageDataSaver: () -> Unit = {}
) {
Column(Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
if (notificationsWarning)
ActionCard(
icon = painterResource(R.drawable.ic_notifications_off),
actionText = stringResource(R.string.account_permissions_action),
onAction = onClickPermissions
) {
Text(stringResource(R.string.account_list_no_notification_permission))
}
if (internetWarning)
ActionCard(
icon = painterResource(R.drawable.ic_signal_cellular_off),
actionText = stringResource(R.string.account_list_manage_connections),
onAction = onManageConnections
) {
Text(stringResource(R.string.account_list_no_internet))
}
if (lowStorageWarning)
ActionCard(
icon = painterResource(R.drawable.ic_storage),
actionText = stringResource(R.string.account_list_manage_storage),
onAction = onManageStorage
) {
Text(stringResource(R.string.account_list_low_storage))
}
if (dataSaverActive)
ActionCard(
icon = painterResource(R.drawable.ic_datasaver_on),
actionText = stringResource(R.string.account_list_manage_datasaver),
onAction = onManageDataSaver
) {
Text(stringResource(R.string.account_list_datasaver_enabled))
}
}
}
@Composable
@Preview
fun SyncWarnings_Preview() {
SyncWarnings(
notificationsWarning = true,
internetWarning = true,
lowStorageWarning = true,
dataSaverActive = true
)
}

View file

@ -13,9 +13,9 @@ import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.Filter
import android.widget.TextView
import at.bitfire.davdroid.util.DavUtils
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.HomeSet
import at.bitfire.davdroid.util.DavUtils
class HomeSetAdapter(
context: Context

View file

@ -5,17 +5,14 @@
package at.bitfire.davdroid.ui.account
import android.content.Intent
import android.os.Bundle
import android.view.*
import androidx.core.view.MenuHost
import androidx.core.view.MenuProvider
import androidx.fragment.app.FragmentManager
import at.bitfire.davdroid.util.PermissionUtils
import at.bitfire.davdroid.R
import at.bitfire.davdroid.databinding.AccountCarddavItemBinding
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.settings.Settings
import at.bitfire.davdroid.settings.SettingsManager
import at.bitfire.davdroid.util.PermissionUtils
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors

View file

@ -5,17 +5,19 @@
package at.bitfire.davdroid.ui.account
import android.content.Intent
import android.os.Bundle
import android.view.*
import androidx.core.view.MenuHost
import androidx.core.view.MenuProvider
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.util.PermissionUtils
import at.bitfire.davdroid.R
import at.bitfire.davdroid.databinding.AccountCaldavItemBinding
import at.bitfire.davdroid.db.Collection
import at.bitfire.davdroid.resource.TaskUtils
import at.bitfire.davdroid.util.PermissionUtils
class CalendarsFragment: CollectionsFragment() {

View file

@ -14,7 +14,6 @@ import android.provider.ContactsContract
import android.view.*
import android.widget.PopupMenu
import androidx.annotation.CallSuper
import androidx.core.view.MenuHost
import androidx.core.view.MenuProvider
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
@ -315,7 +314,7 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList
}
// observe RefreshCollectionsWorker status
val isRefreshing = RefreshCollectionsWorker.isWorkerInState(getApplication(), RefreshCollectionsWorker.workerName(serviceId), WorkInfo.State.RUNNING)
val isRefreshing = RefreshCollectionsWorker.exists(getApplication(), RefreshCollectionsWorker.workerName(serviceId))
// observe SyncWorker state
private val authorities =
@ -335,7 +334,7 @@ abstract class CollectionsFragment: Fragment(), SwipeRefreshLayout.OnRefreshList
// actions
fun refresh() {
RefreshCollectionsWorker.refreshCollections(getApplication(), serviceId)
RefreshCollectionsWorker.enqueue(getApplication(), serviceId)
}
}

View file

@ -147,7 +147,7 @@ class CreateCollectionFragment: DialogFragment() {
db.collectionDao().insert(collection)
// trigger service detection (because the collection may have other properties than the ones we have inserted)
RefreshCollectionsWorker.refreshCollections(getApplication(), service.id)
RefreshCollectionsWorker.enqueue(getApplication(), service.id)
}
// post success

View file

@ -8,8 +8,8 @@ import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.addCallback
import androidx.activity.result.contract.ActivityResultContract
import androidx.annotation.WorkerThread
import androidx.core.content.res.ResourcesCompat
import androidx.fragment.app.Fragment
@ -21,7 +21,6 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.components.ActivityComponent
import javax.inject.Inject
@AndroidEntryPoint
class IntroActivity: AppIntro2() {

View file

@ -10,10 +10,10 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import at.bitfire.davdroid.R
import at.bitfire.davdroid.util.PermissionUtils
import at.bitfire.davdroid.util.PermissionUtils.CALENDAR_PERMISSIONS
import at.bitfire.davdroid.util.PermissionUtils.CONTACT_PERMISSIONS
import at.bitfire.davdroid.R
import at.bitfire.ical4android.TaskProvider
import javax.inject.Inject

View file

@ -212,7 +212,7 @@ class AccountDetailsFragment : Fragment() {
accountSettings.setGroupMethod(groupMethod)
// start CardDAV service detection (refresh collections)
RefreshCollectionsWorker.refreshCollections(context, id)
RefreshCollectionsWorker.enqueue(context, id)
// set default sync interval and enable sync regardless of permissions
ContentResolver.setIsSyncable(account, addrBookAuthority, 1)
@ -226,7 +226,7 @@ class AccountDetailsFragment : Fragment() {
val id = insertService(name, Service.TYPE_CALDAV, config.calDAV)
// start CalDAV service detection (refresh collections)
RefreshCollectionsWorker.refreshCollections(context, id)
RefreshCollectionsWorker.enqueue(context, id)
// set default sync interval and enable sync regardless of permissions
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1)

View file

@ -0,0 +1,67 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/
package at.bitfire.davdroid.ui.widget
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.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import at.bitfire.davdroid.R
@Composable
fun ActionCard(
icon: Painter? = null,
actionText: String? = null,
onAction: () -> Unit = {},
content: @Composable () -> Unit
) {
Card(Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
.fillMaxWidth()
) {
Column(Modifier.padding(top = 8.dp, start = 8.dp, end = 8.dp)) {
if (icon != null)
Row {
Icon(icon, "", Modifier
.align(Alignment.CenterVertically)
.padding(end = 8.dp))
content()
}
else
content()
if (actionText != null)
TextButton(onClick = onAction) {
Text(
actionText.uppercase(),
style = MaterialTheme.typography.button
)
}
}
}
}
@Composable
@Preview
fun ActionCard_Sample() {
ActionCard(
icon = painterResource(R.drawable.ic_notifications_off),
actionText = "Some Action"
) {
Text("Some Content")
}
}

View file

@ -1,178 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical">
<androidx.cardview.widget.CardView
android:id="@+id/no_notifications_info"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
app:contentPadding="@dimen/card_padding">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<androidx.appcompat.widget.AppCompatTextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
app:drawableLeftCompat="@drawable/ic_notifications_off"
app:drawableTint="?android:attr/textColorPrimary"
android:drawablePadding="8dp"
style="@style/TextAppearance.MaterialComponents.Body1"
android:text="@string/account_list_no_notification_permission"/>
<Button
android:id="@+id/allow_notifications"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:text="@string/account_permissions_action" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<androidx.cardview.widget.CardView
android:id="@+id/no_network_info"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
app:contentPadding="@dimen/card_padding">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<androidx.appcompat.widget.AppCompatTextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
app:drawableLeftCompat="@drawable/ic_signal_cellular_off"
app:drawableTint="?android:attr/textColorPrimary"
android:drawablePadding="8dp"
style="@style/TextAppearance.MaterialComponents.Body1"
android:text="@string/account_list_no_internet"/>
<Button
android:id="@+id/manage_connections"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:text="@string/account_list_manage_connections" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<androidx.cardview.widget.CardView
android:id="@+id/low_storage_info"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
app:contentPadding="@dimen/card_padding">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<androidx.appcompat.widget.AppCompatTextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:visibility="visible"
app:drawableLeftCompat="@drawable/ic_storage"
app:drawableTint="?android:attr/textColorPrimary"
android:drawablePadding="8dp"
style="@style/TextAppearance.MaterialComponents.Body1"
android:text="@string/account_list_low_storage"/>
<Button
android:id="@+id/manage_storage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:text="@string/account_list_manage_storage" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<androidx.cardview.widget.CardView
android:id="@+id/datasaver_on_info"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
app:contentPadding="@dimen/card_padding">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<androidx.appcompat.widget.AppCompatTextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:visibility="visible"
app:drawableLeftCompat="@drawable/ic_datasaver_on"
app:drawableTint="?android:attr/textColorPrimary"
android:drawablePadding="8dp"
style="@style/TextAppearance.MaterialComponents.Body1"
android:text="@string/account_list_datasaver_enabled"/>
<Button
android:id="@+id/manage_datasaver"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:text="@string/account_list_manage_datasaver" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<androidx.recyclerview.widget.RecyclerView
android:id="@android:id/list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp"
android:divider="@android:color/transparent"
android:background="@android:color/transparent"
android:cacheColorHint="@android:color/transparent" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@android:id/empty"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/image"
android:layout_width="wrap_content"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/text"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias=".5"
android:paddingTop="16dp"
android:paddingBottom="16dp"
android:adjustViewBounds="true"
app:srcCompat="@drawable/accounts_background"/>
<TextView
android:id="@+id/text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:layout_marginBottom="24dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/image"
android:gravity="center"
style="@style/TextAppearance.MaterialComponents.Headline6"
android:text="@string/account_list_empty" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>

View file

@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:descendantFocusability="blocksDescendants">
<androidx.cardview.widget.CardView
style="@style/account_list_card"
android:layout_margin="8dp"
android:layout_height="wrap_content"
android:foreground="?android:attr/selectableItemBackground"
app:cardBackgroundColor="@color/cardview_background"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="vertical">
<ProgressBar
android:id="@+id/progress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:progressTint="?attr/colorOnSecondary"
android:indeterminate="true"
android:indeterminateTint="?attr/colorOnSecondary"/>
<ImageView
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginTop="8dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
app:tint="?attr/colorOnSecondary"
app:srcCompat="@drawable/ic_account_circle_white" />
<TextView
android:id="@+id/account_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="16dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline5"
android:textColor="?attr/colorOnSecondary"
tools:text="Account Name"/>
</LinearLayout>
</androidx.cardview.widget.CardView>
</LinearLayout>

View file

@ -1,41 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/coordinator"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="at.bitfire.davdroid.ui.AccountsActivity">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.appcompat.widget.Toolbar
android:theme="?attr/actionBarTheme"
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"/>
</com.google.android.material.appbar.AppBarLayout>
<androidx.fragment.app.FragmentContainerView
android:id="@+id/account_list"
android:name="at.bitfire.davdroid.ui.AccountListFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:layout="@layout/account_list"
app:layout_scrollFlags="scroll"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"/>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="@dimen/fab_margin"
android:contentDescription="@string/login_create_account"
app:srcCompat="@drawable/ic_add_white"/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -1,24 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout
android:id="@+id/drawer_layout"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:openDrawer="start">
<include
android:id="@+id/content"
layout="@layout/accounts_content"/>
<com.google.android.material.navigation.NavigationView
android:id="@+id/nav_view"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
android:fitsSystemWindows="true"
app:headerLayout="@layout/nav_header_accounts"
app:menu="@menu/activity_accounts_drawer"/>
</androidx.drawerlayout.widget.DrawerLayout>

View file

@ -16,8 +16,9 @@
<string name="help">Help</string>
<string name="manage_accounts">Manage accounts</string>
<string name="navigate_up">Navigate up</string>
<string name="no_internet_sync_scheduled">No Internet, scheduling sync</string>
<string name="share">Share</string>
<string name="no_internet_sync_scheduled">No internet, scheduling sync</string>
<string name="sync_started">Synchronization started</string>
<string name="database_destructive_migration_title">Database corrupted</string>
<string name="database_destructive_migration_text">All accounts have been removed locally.</string>

View file

@ -5,6 +5,7 @@
buildscript {
ext.versions = [
aboutLibraries: '10.9.1',
accompanist: '0.30.1',
appIntro: '7.0.0-beta02',
composeBom: '2023.10.01',
hilt: '2.48.1',