Rewrite AccountsActivity to M3 (#749)

* [WIP] Rewrite AccountsActivity to M3

* [WIP] AccountsScreen: FAB, previews

* [WIP] Warning cards

* Adapt FABs
This commit is contained in:
Ricki Hirner 2024-04-26 11:20:55 +02:00 committed by GitHub
parent 0c748ebe73
commit c33ea84c77
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 815 additions and 759 deletions

View file

@ -23,6 +23,9 @@ interface ServiceDao {
@Query("SELECT id FROM service WHERE accountName=:accountName")
fun getIdsByAccount(accountName: String): List<Long>
@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>

View file

@ -6,6 +6,7 @@ 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
@ -23,6 +24,8 @@ 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
@ -39,7 +42,7 @@ class AccountRepository @Inject constructor(
) {
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
@ -142,8 +145,22 @@ class AccountRepository @Inject constructor(
if (accountName.isEmpty())
false
else
AccountManager.get(context)
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)
}
}
}

View file

@ -4,576 +4,67 @@
package at.bitfire.davdroid.ui
import android.Manifest
import android.accounts.Account
import android.accounts.AccountManager
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.provider.Settings
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
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.fillMaxSize
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.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.Scaffold
import androidx.compose.material.ScaffoldState
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.BatterySaver
import androidx.compose.material.icons.filled.DataSaverOn
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.material.icons.filled.SignalCellularOff
import androidx.compose.material.icons.filled.Storage
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.draw.alpha
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.core.content.getSystemService
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.db.AppDatabase
import at.bitfire.davdroid.servicedetection.RefreshCollectionsWorker
import at.bitfire.davdroid.syncadapter.BaseSyncWorker
import at.bitfire.davdroid.syncadapter.OneTimeSyncWorker
import at.bitfire.davdroid.syncadapter.SyncUtils
import at.bitfire.davdroid.ui.account.AccountActivity
import at.bitfire.davdroid.ui.account.progressAlpha
import at.bitfire.davdroid.ui.composable.ActionCard
import at.bitfire.davdroid.ui.intro.IntroActivity
import at.bitfire.davdroid.ui.setup.LoginActivity
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
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() {
@Inject lateinit var accountsDrawerHandler: AccountsDrawerHandler
private val model by viewModels<Model>()
private val warnings by viewModels<AppWarningsModel>()
@Inject
lateinit var accountsDrawerHandler: AccountsDrawerHandler
private val introActivityLauncher = registerForActivityResult(IntroActivity.Contract) { cancelled ->
if (cancelled)
finish()
}
val model by viewModels<AccountsModel>()
@OptIn(ExperimentalMaterialApi::class, ExperimentalPermissionsApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// use a separate thread to check whether IntroActivity should be shown
if (savedInstanceState == null) {
// use a separate thread to check whether IntroActivity should be shown
// move to Model
CoroutineScope(Dispatchers.Default).launch {
if (IntroActivity.shouldShowIntroActivity(this@AccountsActivity))
introActivityLauncher.launch(null)
}
}
setContent {
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val scaffoldState = rememberScaffoldState(
snackbarHostState = snackbarHostState
)
val refreshing by remember { mutableStateOf(false) }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = {
model.syncAllAccounts()
})
val accounts by model.accountInfos.observeAsState()
M2Theme {
Scaffold(
scaffoldState = scaffoldState,
drawerContent = {
accountsDrawerHandler.AccountsDrawer(
snackbarHostState = snackbarHostState,
onCloseDrawer = {
scope.launch {
scaffoldState.drawerState.close()
}
}
)
},
topBar = topBar(scope, scaffoldState, accounts?.isNotEmpty() == true),
floatingActionButton = floatingActionButton()
) { padding ->
Box(
Modifier
.fillMaxSize()
.padding(padding)
.pullRefresh(
state = pullRefreshState,
enabled = accounts?.isNotEmpty() == true
)
.verticalScroll(rememberScrollState())
) {
// background image
Image(
painterResource(R.drawable.accounts_background),
contentDescription = null,
modifier = Modifier
.matchParentSize()
.align(Alignment.Center)
)
Column {
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(Settings.ACTION_WIRELESS_SETTINGS)
if (intent.resolveActivity(packageManager) != null)
startActivity(intent)
},
dataSaverActive = warnings.dataSaverEnabled.collectAsStateWithLifecycle().value,
onManageDataSaver = {
val intent = Intent(Settings.ACTION_IGNORE_BACKGROUND_DATA_RESTRICTIONS_SETTINGS, Uri.parse("package:$packageName"))
if (intent.resolveActivity(packageManager) != null)
startActivity(intent)
},
batterySaverActive = warnings.batterySaverActive.collectAsStateWithLifecycle().value,
onManageBatterySaver = {
val intent = Intent(Settings.ACTION_BATTERY_SAVER_SETTINGS)
if (intent.resolveActivity(packageManager) != null)
startActivity(intent)
},
lowStorageWarning = warnings.storageLow.collectAsStateWithLifecycle().value,
onManageStorage = {
val intent = Intent(Settings.ACTION_INTERNAL_STORAGE_SETTINGS)
if (intent.resolveActivity(packageManager) != null)
startActivity(intent)
}
)
// account list
AccountList(
accounts = accounts ?: emptyMap(),
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)
model.syncAllAccounts()
}
@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,
accountsNotEmpty: Boolean
): @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)
)
setContent {
AccountsScreen(
accountsDrawerHandler = accountsDrawerHandler,
onAddAccount = {
startActivity(Intent(this, LoginActivity::class.java))
},
onShowAccount = { account ->
val intent = Intent(this, AccountActivity::class.java)
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
startActivity(intent)
},
onManagePermissions = {
startActivity(Intent(this, PermissionsActivity::class.java))
}
},
title = {
Text(stringResource(R.string.app_name))
},
actions = {
if (accountsNotEmpty) {
IconButton(onClick = { model.syncAllAccounts() }) {
Icon(
painterResource(R.drawable.ic_sync),
contentDescription = stringResource(R.string.accounts_sync_all)
)
}
}
}
)
}
@HiltViewModel
class Model @Inject constructor(
application: Application,
val db: AppDatabase
): AndroidViewModel(application), OnAccountsUpdateListener {
val accountManager = AccountManager.get(application)
private val accountType = application.getString(R.string.account_type)
val workManager = WorkManager.getInstance(application)
val runningWorkers = workManager.getWorkInfosLiveData(WorkQuery.fromStates(WorkInfo.State.ENQUEUED, WorkInfo.State.RUNNING))
val accounts = MutableLiveData<Set<Account>>()
val accountInfos = object: MediatorLiveData<Map<Account, AccountActivity.Progress>>() {
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)
val collator = Collator.getInstance()
postValue(myAccounts
.sortedWith { a, b -> collator.compare(a.name, b.name) }
.associateWith { account ->
val services = db.serviceDao().getIdsByAccount(account.name)
when {
workInfos.any { info ->
info.state == WorkInfo.State.RUNNING && (
services.any { serviceId ->
info.tags.contains(RefreshCollectionsWorker.workerName(serviceId))
} || authorities.any { authority ->
info.tags.contains(BaseSyncWorker.commonTag(account, authority))
}
)
} -> AccountActivity.Progress.Active
workInfos.any { info ->
info.state == WorkInfo.State.ENQUEUED && authorities.any { authority ->
info.tags.contains(OneTimeSyncWorker.workerName(account, authority))
}
} -> AccountActivity.Progress.Pending
else -> AccountActivity.Progress.Idle
}
})
}
}
val showAddAccount = MutableLiveData(true)
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)
// Enqueue sync worker for all accounts and authorities. Will sync once internet is available
for (account in allAccounts())
OneTimeSyncWorker.enqueueAllAuthorities(context, account, manual = true)
}
// helpers
private fun allAccounts() =
AccountManager.get(getApplication()).getAccountsByType(accountType)
}
}
@Composable
fun AccountList(
accounts: Map<Account, AccountActivity.Progress>,
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, progress) in accounts)
Card(
backgroundColor = MaterialTheme.colors.secondaryVariant,
contentColor = MaterialTheme.colors.onSecondary,
modifier = Modifier
.clickable { onClickAccount(account) }
.fillMaxWidth()
.padding(8.dp)
) {
Column {
val progressAlpha = progressAlpha(progress)
when (progress) {
AccountActivity.Progress.Active ->
LinearProgressIndicator(
color = MaterialTheme.colors.onSecondary,
modifier = Modifier
.alpha(progressAlpha)
.fillMaxWidth()
)
AccountActivity.Progress.Pending,
AccountActivity.Progress.Idle ->
LinearProgressIndicator(
progress = 1f,
color = MaterialTheme.colors.onSecondary,
modifier = Modifier
.alpha(progressAlpha)
.fillMaxWidth()
)
}
Column(Modifier.padding(8.dp)) {
Icon(
imageVector = Icons.Default.AccountCircle,
contentDescription = null,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.size(48.dp)
)
Text(
text = account.name,
style = MaterialTheme.typography.h5,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(top = 8.dp)
.fillMaxWidth()
)
}
}
}
}
}
@Composable
@Preview
fun AccountList_Preview_Idle() {
AccountList(mapOf(
Account("Account Name", "test") to AccountActivity.Progress.Idle
))
}
@Composable
@Preview
fun AccountList_Preview_SyncPending() {
AccountList(mapOf(
Account("Account Name", "test") to AccountActivity.Progress.Pending
))
}
@Composable
@Preview
fun AccountList_Preview_Syncing() {
AccountList(mapOf(
Account("Account Name", "test") to AccountActivity.Progress.Active
))
}
@Composable
@Preview
fun AccountList_Preview_Empty() {
AccountList(emptyMap())
}
@Composable
fun SyncWarnings(
notificationsWarning: Boolean,
onClickPermissions: () -> Unit = {},
internetWarning: Boolean,
onManageConnections: () -> Unit = {},
batterySaverActive: Boolean,
onManageBatterySaver: () -> Unit = {},
dataSaverActive: Boolean,
onManageDataSaver: () -> Unit = {},
lowStorageWarning: Boolean,
onManageStorage: () -> Unit = {}
) {
Column(Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
if (notificationsWarning)
ActionCard(
icon = Icons.Default.NotificationsOff,
actionText = stringResource(R.string.account_manage_permissions),
onAction = onClickPermissions
) {
Text(stringResource(R.string.account_list_no_notification_permission))
}
if (internetWarning)
ActionCard(
icon = Icons.Default.SignalCellularOff,
actionText = stringResource(R.string.account_list_manage_connections),
onAction = onManageConnections
) {
Text(stringResource(R.string.account_list_no_internet))
}
if (batterySaverActive)
ActionCard(
icon = Icons.Default.BatterySaver,
actionText = stringResource(R.string.account_list_manage_battery_saver),
onAction = onManageBatterySaver
) {
Text(stringResource(R.string.account_list_battery_saver_enabled))
}
if (dataSaverActive)
ActionCard(
icon = Icons.Default.DataSaverOn,
actionText = stringResource(R.string.account_list_manage_datasaver),
onAction = onManageDataSaver
) {
Text(stringResource(R.string.account_list_datasaver_enabled))
}
if (lowStorageWarning)
ActionCard(
icon = Icons.Default.Storage,
actionText = stringResource(R.string.account_list_manage_storage),
onAction = onManageStorage
) {
Text(stringResource(R.string.account_list_low_storage))
}
}
}
@Composable
@Preview
fun SyncWarnings_Preview() {
SyncWarnings(
notificationsWarning = true,
internetWarning = true,
batterySaverActive = true,
dataSaverActive = true,
lowStorageWarning = true
)
}

View file

@ -9,34 +9,32 @@ import android.content.Context
import android.content.Intent
import androidx.annotation.StringRes
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.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.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.SnackbarHostState
import androidx.compose.material.SnackbarResult
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Feedback
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
@ -47,6 +45,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import at.bitfire.davdroid.BuildConfig
@ -77,7 +76,7 @@ abstract class AccountsDrawerHandler {
snackbarHostState: SnackbarHostState,
onCloseDrawer: () -> Unit
) {
Column(Modifier
Column(modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
@ -181,15 +180,14 @@ abstract class AccountsDrawerHandler {
fun Header() {
Column(
Modifier
.background(Color.DarkGray)
.fillMaxWidth()
.padding(16.dp)
.background(Color.DarkGray)
.fillMaxWidth()
.padding(16.dp)
) {
Spacer(Modifier.height(16.dp))
Box(
Modifier
.background(
color = MaterialTheme.colors.primary,
Modifier.background(
color = MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(16.dp)
)
) {
@ -199,8 +197,7 @@ abstract class AccountsDrawerHandler {
tint = Color.White,
modifier = Modifier
.scale(1.2f)
.height(56.dp)
.width(56.dp)
.size(64.dp)
)
}
Spacer(Modifier.height(8.dp))
@ -208,12 +205,12 @@ abstract class AccountsDrawerHandler {
Text(
stringResource(R.string.app_name),
color = Color.White,
style = MaterialTheme.typography.body1
style = MaterialTheme.typography.bodyLarge
)
Text(
stringResource(R.string.navigation_drawer_subtitle),
color = Color.White.copy(alpha = 0.7f),
style = MaterialTheme.typography.body2
style = MaterialTheme.typography.bodyMedium
)
}
@ -222,11 +219,11 @@ abstract class AccountsDrawerHandler {
@Composable
fun MenuHeading(text: String) {
Divider(Modifier.padding(vertical = 8.dp))
HorizontalDivider(Modifier.padding(vertical = 8.dp))
Text(
text,
style = MaterialTheme.typography.body2,
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold),
modifier = Modifier.padding(8.dp)
)
}
@ -241,28 +238,15 @@ abstract class AccountsDrawerHandler {
onClick: () -> Unit
) {
val closeHandler = localCloseDrawerHandler.current
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable(onClick = {
onClick()
closeHandler.closeDrawer()
})
.padding(4.dp)
) {
Icon(
icon,
contentDescription = title,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
Text(
title,
style = MaterialTheme.typography.body2,
modifier = Modifier
.padding(start = 8.dp)
.weight(1f)
)
}
NavigationDrawerItem(
icon = { Icon(icon, contentDescription = title) },
label = { Text(title) },
selected = false,
onClick = {
onClick()
closeHandler.closeDrawer()
}
)
}
@Composable

View file

@ -0,0 +1,187 @@
package at.bitfire.davdroid.ui
import android.accounts.Account
import android.app.Application
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ShortcutManager
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import android.os.PowerManager
import androidx.core.content.getSystemService
import androidx.lifecycle.ViewModel
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkQuery
import at.bitfire.davdroid.db.AppDatabase
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.util.broadcastReceiverFlow
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import java.text.Collator
import javax.inject.Inject
@HiltViewModel
class AccountsModel @Inject constructor(
val context: Application,
val accountRepository: AccountRepository,
private val db: AppDatabase
): ViewModel() {
// 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,
None
}
data class AccountInfo(
val name: Account,
val progress: Progress
)
private val accounts = accountRepository.getAllFlow()
val showAddAccount: Flow<FABStyle> = accounts.map {
if (it.isEmpty())
FABStyle.WithText
else
FABStyle.Standard
}
val showSyncAll: Flow<Boolean> = accounts.map { it.isNotEmpty() }
private val workManager = WorkManager.getInstance(context)
private val runningWorkers = workManager.getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.ENQUEUED, WorkInfo.State.RUNNING))
val accountInfos: Flow<List<AccountInfo>> = combine(accounts, runningWorkers) { accounts, workInfos ->
val authorities = SyncUtils.syncAuthorities(context)
val collator = Collator.getInstance()
accounts
.sortedWith { a, b -> collator.compare(a.name, b.name) }
.map { account ->
val services = db.serviceDao().getIdsByAccountAsync(account.name)
val progress = when {
workInfos.any { info ->
info.state == WorkInfo.State.RUNNING && (
services.any { serviceId ->
info.tags.contains(RefreshCollectionsWorker.workerName(serviceId))
} || authorities.any { authority ->
info.tags.contains(BaseSyncWorker.commonTag(account, authority))
}
)
} -> Progress.Active
workInfos.any { info ->
info.state == WorkInfo.State.ENQUEUED && authorities.any { authority ->
info.tags.contains(OneTimeSyncWorker.workerName(account, authority))
}
} -> Progress.Pending
else -> Progress.Idle
}
AccountInfo(account, progress)
}
}
// warnings
private val connectivityManager = context.getSystemService<ConnectivityManager>()!!
private val powerManager: PowerManager = context.getSystemService<PowerManager>()!!
/** whether a usable network connection is available (sync framework won't run synchronization otherwise) */
val networkAvailable = callbackFlow<Boolean> {
val networkCallback = object: ConnectivityManager.NetworkCallback() {
val availableNetworks = hashSetOf<Network>()
override fun onAvailable(network: Network) {
availableNetworks += network
update()
}
override fun onLost(network: Network) {
availableNetworks -= network
update()
}
private fun update() {
trySend(availableNetworks.isNotEmpty())
}
}
val networkRequest = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
.build()
connectivityManager.registerNetworkCallback(networkRequest, networkCallback)
awaitClose {
connectivityManager.unregisterNetworkCallback(networkCallback)
}
}
/** whether battery saver is active */
val batterySaverActive =
broadcastReceiverFlow(
context = context,
filter = IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED),
immediate = true
).map { powerManager.isPowerSaveMode }
/** whether data saver is restricting background synchronization ([ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED]) */
val dataSaverEnabled =
broadcastReceiverFlow(
context = context,
filter = IntentFilter(ConnectivityManager.ACTION_RESTRICT_BACKGROUND_CHANGED),
immediate = true
).map { connectivityManager.restrictBackgroundStatus == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED }
/** whether storage is low (prevents sync framework from running synchronization) */
val storageLow =
broadcastReceiverFlow(
context = context,
filter = IntentFilter().apply {
addAction(Intent.ACTION_DEVICE_STORAGE_LOW)
addAction(Intent.ACTION_DEVICE_STORAGE_OK)
},
immediate = false // "storage low" intent is sticky
).map { intent ->
when (intent.action) {
Intent.ACTION_DEVICE_STORAGE_LOW -> true
else -> false
}
}
// actions
fun syncAllAccounts() {
if (Build.VERSION.SDK_INT >= 25)
context.getSystemService<ShortcutManager>()?.reportShortcutUsed(UiUtils.SHORTCUT_SYNC_ALL)
// Enqueue sync worker for all accounts and authorities. Will sync once internet is available
for (account in accountRepository.getAll())
OneTimeSyncWorker.enqueueAllAuthorities(context, account, manual = true)
}
}

View file

@ -0,0 +1,515 @@
package at.bitfire.davdroid.ui
import android.Manifest
import android.accounts.Account
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.activity.compose.BackHandler
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.fillMaxSize
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.verticalScroll
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.BatterySaver
import androidx.compose.material.icons.filled.DataSaverOn
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.material.icons.filled.SignalCellularOff
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconToggleButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.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.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.composable.ActionCard
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.coroutines.launch
@Composable
fun AccountsScreen(
accountsDrawerHandler: AccountsDrawerHandler,
onAddAccount: () -> Unit,
onShowAccount: (Account) -> Unit,
onManagePermissions: () -> Unit,
model: AccountsModel = viewModel()
) {
val accounts by model.accountInfos.collectAsStateWithLifecycle(emptyList())
val showSyncAll by model.showSyncAll.collectAsStateWithLifecycle(true)
val showAddAccount by model.showAddAccount.collectAsStateWithLifecycle(AccountsModel.FABStyle.Standard)
AccountsScreen(
accountsDrawerHandler = accountsDrawerHandler,
accounts = accounts,
showSyncAll = showSyncAll,
onSyncAll = { model.syncAllAccounts() },
showAddAccount = showAddAccount,
onAddAccount = onAddAccount,
onShowAccount = onShowAccount,
onManagePermissions = onManagePermissions,
internetUnavailable = !model.networkAvailable.collectAsStateWithLifecycle(false).value,
batterySaverActive = model.batterySaverActive.collectAsStateWithLifecycle(false).value,
dataSaverActive = model.dataSaverEnabled.collectAsStateWithLifecycle(false).value,
storageLow = model.storageLow.collectAsStateWithLifecycle(false).value
)
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
@Composable
fun AccountsScreen(
accountsDrawerHandler: AccountsDrawerHandler,
accounts: List<AccountsModel.AccountInfo>,
showSyncAll: Boolean = true,
onSyncAll: () -> Unit = {},
showAddAccount: AccountsModel.FABStyle = AccountsModel.FABStyle.Standard,
onAddAccount: () -> Unit = {},
onShowAccount: (Account) -> Unit = {},
onManagePermissions: () -> Unit = {},
internetUnavailable: Boolean = false,
batterySaverActive: Boolean = false,
dataSaverActive: Boolean = false,
storageLow: Boolean = false
) {
val scope = rememberCoroutineScope()
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
BackHandler(drawerState.isOpen) {
scope.launch {
drawerState.close()
}
}
val refreshState = rememberPullToRefreshState(
enabled = { showSyncAll }
)
LaunchedEffect(refreshState.isRefreshing) {
if (refreshState.isRefreshing) {
onSyncAll()
refreshState.endRefresh()
}
}
val snackbarHostState = remember { SnackbarHostState() }
AppTheme {
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconToggleButton(false, onCheckedChange = { openDrawer ->
scope.launch {
if (openDrawer)
drawerState.open()
else
drawerState.close()
}
}) {
Icon(
Icons.Filled.Menu,
stringResource(androidx.compose.ui.R.string.navigation_menu)
)
}
},
title = {
Text(stringResource(R.string.app_name))
}
)
},
floatingActionButton = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
if (showSyncAll)
FloatingActionButton(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
onClick = onSyncAll
) {
Icon(
Icons.Default.Sync,
contentDescription = stringResource(R.string.accounts_sync_all)
)
}
if (showAddAccount == AccountsModel.FABStyle.WithText)
ExtendedFloatingActionButton(
text = { Text(stringResource(R.string.login_create_account)) },
icon = { Icon(Icons.Filled.Add, stringResource(R.string.login_create_account)) },
onClick = onAddAccount
)
else if (showAddAccount == AccountsModel.FABStyle.Standard)
FloatingActionButton(
onClick = onAddAccount,
modifier = Modifier.padding(top = 24.dp)
) {
Icon(Icons.Filled.Add, stringResource(R.string.login_create_account))
}
}
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { padding ->
Box(Modifier.padding(padding)) {
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet {
accountsDrawerHandler.AccountsDrawer(
snackbarHostState = snackbarHostState,
onCloseDrawer = {
scope.launch {
drawerState.close()
}
}
)
}
}
) {
Box(
Modifier
.fillMaxSize()
.nestedScroll(refreshState.nestedScrollConnection)
.verticalScroll(rememberScrollState())
) {
// background image
Image(
painterResource(R.drawable.accounts_background),
contentDescription = null,
modifier = Modifier
.matchParentSize()
.align(Alignment.Center)
)
Column {
val notificationsPermissionState =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !LocalInspectionMode.current)
rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
else
null
// Warnings show as action cards
val context = LocalContext.current
SyncWarnings(
notificationsWarning = notificationsPermissionState?.status?.isGranted == false,
onManagePermissions = onManagePermissions,
internetWarning = internetUnavailable,
onManageConnections = {
val intent = Intent(Settings.ACTION_WIRELESS_SETTINGS)
if (intent.resolveActivity(context.packageManager) != null)
context.startActivity(intent)
},
batterySaverActive = batterySaverActive,
onManageBatterySaver = {
val intent = Intent(Settings.ACTION_BATTERY_SAVER_SETTINGS)
if (intent.resolveActivity(context.packageManager) != null)
context.startActivity(intent)
},
dataSaverActive = dataSaverActive,
onManageDataSaver = {
val intent = Intent(
/* action = */ Settings.ACTION_IGNORE_BACKGROUND_DATA_RESTRICTIONS_SETTINGS,
/* uri = */ Uri.parse("package:${BuildConfig.APPLICATION_ID}")
)
if (intent.resolveActivity(context.packageManager) != null)
context.startActivity(intent)
},
lowStorageWarning = storageLow,
onManageStorage = {
val intent = Intent(Settings.ACTION_INTERNAL_STORAGE_SETTINGS)
if (intent.resolveActivity(context.packageManager) != null)
context.startActivity(intent)
}
)
// account list
AccountList(
accounts = accounts,
onClickAccount = { account ->
onShowAccount(account)
},
modifier = Modifier
.fillMaxSize()
.padding(8.dp)
)
}
// indicate when the user pulls down
PullToRefreshContainer(
modifier = Modifier.align(Alignment.TopCenter),
state = refreshState
)
}
}
}
}
}
}
@Composable
@Preview
fun AccountsScreen_Preview_Empty() {
AccountsScreen(
accountsDrawerHandler = object: AccountsDrawerHandler() {
@Composable
override fun MenuEntries(snackbarHostState: SnackbarHostState) {
Text("Menu entries")
}
},
accounts = emptyList()
)
}
@Composable
@Preview
fun AccountsScreen_Preview_OneAccount() {
AccountsScreen(
accountsDrawerHandler = object: AccountsDrawerHandler() {
@Composable
override fun MenuEntries(snackbarHostState: SnackbarHostState) {
Text("Menu entries")
}
},
accounts = listOf(
AccountsModel.AccountInfo(
Account("Account Name", "test"),
AccountsModel.Progress.Idle
)
)
)
}
@Composable
fun AccountList(
accounts: List<AccountsModel.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.headlineSmall,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
else
for ((account, progress) in accounts)
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer
),
elevation = CardDefaults.cardElevation(1.dp),
modifier = Modifier
.clickable { onClickAccount(account) }
.fillMaxWidth()
.padding(vertical = 4.dp)
) {
Column {
val progressAlpha = progressAlpha(progress)
when (progress) {
AccountsModel.Progress.Active ->
LinearProgressIndicator(
//color = MaterialTheme.colors.onSecondary,
modifier = Modifier
.alpha(progressAlpha)
.fillMaxWidth()
)
AccountsModel.Progress.Pending,
AccountsModel.Progress.Idle ->
LinearProgressIndicator(
progress = 1f,
//color = MaterialTheme.colors.onSecondary,
modifier = Modifier
.alpha(progressAlpha)
.fillMaxWidth()
)
}
Column(Modifier.padding(8.dp)) {
Icon(
imageVector = Icons.Default.AccountCircle,
contentDescription = null,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.size(48.dp)
)
Text(
text = account.name,
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(top = 8.dp)
.fillMaxWidth()
)
}
}
}
}
}
@Composable
@Preview
fun AccountList_Preview_Idle() {
AccountList(
listOf(
AccountsModel.AccountInfo(
Account("Account Name", "test"),
AccountsModel.Progress.Idle
)
)
)
}
@Composable
@Preview
fun AccountList_Preview_SyncPending() {
AccountList(listOf(
AccountsModel.AccountInfo(
Account("Account Name", "test"),
AccountsModel.Progress.Pending
)
))
}
@Composable
@Preview
fun AccountList_Preview_Syncing() {
AccountList(listOf(
AccountsModel.AccountInfo(
Account("Account Name", "test"),
AccountsModel.Progress.Active
)
))
}
@Composable
fun SyncWarnings(
notificationsWarning: Boolean = true,
onManagePermissions: () -> Unit = {},
internetWarning: Boolean = true,
onManageConnections: () -> Unit = {},
batterySaverActive: Boolean = true,
onManageBatterySaver: () -> Unit = {},
dataSaverActive: Boolean = true,
onManageDataSaver: () -> Unit = {},
lowStorageWarning: Boolean = true,
onManageStorage: () -> Unit = {}
) {
Column(Modifier.padding(horizontal = 8.dp)) {
if (notificationsWarning)
ActionCard(
icon = Icons.Default.NotificationsOff,
actionText = stringResource(R.string.account_manage_permissions),
onAction = onManagePermissions,
modifier = Modifier.padding(vertical = 4.dp)
) {
Text(stringResource(R.string.account_list_no_notification_permission))
}
if (internetWarning)
ActionCard(
icon = Icons.Default.SignalCellularOff,
actionText = stringResource(R.string.account_list_manage_connections),
onAction = onManageConnections,
modifier = Modifier.padding(vertical = 4.dp)
) {
Text(stringResource(R.string.account_list_no_internet))
}
if (batterySaverActive)
ActionCard(
icon = Icons.Default.BatterySaver,
actionText = stringResource(R.string.account_list_manage_battery_saver),
onAction = onManageBatterySaver,
modifier = Modifier.padding(vertical = 4.dp)
) {
Text(stringResource(R.string.account_list_battery_saver_enabled))
}
if (dataSaverActive)
ActionCard(
icon = Icons.Default.DataSaverOn,
actionText = stringResource(R.string.account_list_manage_datasaver),
onAction = onManageDataSaver,
modifier = Modifier.padding(vertical = 4.dp)
) {
Text(stringResource(R.string.account_list_datasaver_enabled))
}
if (lowStorageWarning)
ActionCard(
icon = Icons.Default.Storage,
actionText = stringResource(R.string.account_list_manage_storage),
onAction = onManageStorage,
modifier = Modifier.padding(vertical = 4.dp)
) {
Text(stringResource(R.string.account_list_low_storage))
}
}
}
@Composable
@Preview
fun SyncWarnings_Preview() {
SyncWarnings(
notificationsWarning = true,
internetWarning = true,
batterySaverActive = true,
dataSaverActive = true,
lowStorageWarning = true
)
}

View file

@ -498,7 +498,7 @@ class AppSettingsActivity: AppCompatActivity() {
private val preferences = PreferenceManager.getDefaultSharedPreferences(context)
private val powerManager = context.getSystemService<PowerManager>()!!
val batterySavingExempted = broadcastReceiverFlow(context, IntentFilter(PermissionUtils.ACTION_POWER_SAVE_WHITELIST_CHANGED))
val batterySavingExempted = broadcastReceiverFlow(context, IntentFilter(PermissionUtils.ACTION_POWER_SAVE_WHITELIST_CHANGED), immediate = true)
.map { powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)

View file

@ -1,137 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui
import android.app.Application
import android.content.ContentResolver
import android.content.Intent
import android.content.IntentFilter
import android.content.SyncStatusObserver
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.PowerManager
import androidx.core.content.getSystemService
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.bitfire.davdroid.util.broadcastReceiverFlow
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
/**
* Watches some conditions that result in *Warnings* that should
* be shown to the user in the launcher activity. The variables are
* available as LiveData so they can be directly observed in the UI.
*
* Currently watches:
*
* - whether storage is low [storageLow]
* - whether global sync is disabled [globalSyncDisabled]
* - whether a network connection is available [networkAvailable]
* - whether data saver is turned on -> [dataSaverEnabled]
*/
@HiltViewModel
class AppWarningsModel @Inject constructor(
val context: Application
): ViewModel(), SyncStatusObserver {
/** whether storage is low (prevents sync framework from running synchronization) */
val storageLow =
broadcastReceiverFlow(
context = context,
filter = IntentFilter().apply {
addAction(Intent.ACTION_DEVICE_STORAGE_LOW)
addAction(Intent.ACTION_DEVICE_STORAGE_OK)
}
).map { intent ->
when (intent.action) {
Intent.ACTION_DEVICE_STORAGE_LOW -> true
else -> false
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
/** whether global sync is disabled (sync framework won't run automatic synchronization in this case) */
val globalSyncDisabled = MutableLiveData<Boolean>()
private var syncStatusObserver: Any? = null
/** whether a usable network connection is available (sync framework won't run synchronization otherwise) */
val networkAvailable = MutableLiveData<Boolean>()
private lateinit var networkCallback: ConnectivityManager.NetworkCallback
private val powerManager = context.getSystemService<PowerManager>()!!
/** whether battery saver is active */
val batterySaverActive =
broadcastReceiverFlow(
context = context,
filter = IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED),
immediate = true
).map { powerManager.isPowerSaveMode }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
private val connectivityManager = context.getSystemService<ConnectivityManager>()!!
/** whether data saver is restricting background synchronization ([ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED]) */
val dataSaverEnabled =
broadcastReceiverFlow(
context = context,
filter = IntentFilter(ConnectivityManager.ACTION_RESTRICT_BACKGROUND_CHANGED),
immediate = true
).map { connectivityManager.restrictBackgroundStatus == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)
init {
// Automatic Sync
syncStatusObserver = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, this)
onStatusChanged(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS)
// Network
watchConnectivity()
}
override fun onCleared() {
// Automatic sync
ContentResolver.removeStatusChangeListener(syncStatusObserver)
// Network
connectivityManager.unregisterNetworkCallback(networkCallback)
}
override fun onStatusChanged(which: Int) {
globalSyncDisabled.postValue(!ContentResolver.getMasterSyncAutomatically())
}
private fun watchConnectivity() {
networkAvailable.postValue(false)
// check for working (e.g. WiFi after captive portal login) Internet connection
val networkRequest = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
.build()
networkCallback = object: ConnectivityManager.NetworkCallback() {
val availableNetworks = hashSetOf<Network>()
override fun onAvailable(network: Network) {
availableNetworks += network
update()
}
override fun onLost(network: Network) {
availableNetworks -= network
update()
}
private fun update() {
networkAvailable.postValue(availableNetworks.isNotEmpty())
}
}
connectivityManager.registerNetworkCallback(networkRequest, networkCallback)
}
}

View file

@ -5,7 +5,6 @@
package at.bitfire.davdroid.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.material.SnackbarHostState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.HelpCenter
import androidx.compose.material.icons.filled.CloudOff
@ -13,6 +12,7 @@ import androidx.compose.material.icons.filled.Forum
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.VolunteerActivism
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource

View file

@ -80,6 +80,7 @@ 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
@ -109,13 +110,6 @@ class AccountActivity : AppCompatActivity() {
}
}
/** Tri-state enum to represent active / pending / idle status */
enum class Progress {
Active, // syncing or refreshing
Pending, // sync pending
Idle
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -139,10 +133,10 @@ class AccountActivity : AppCompatActivity() {
val cardDavRefreshing by model.cardDavRefreshing.observeAsState(false)
val cardDavSyncPending by model.cardDavSyncPending.observeAsState(false)
val cardDavSyncing by model.cardDavSyncing.observeAsState(false)
val cardDavProgress: Progress = when {
cardDavRefreshing || cardDavSyncing -> Progress.Active
cardDavSyncPending -> Progress.Pending
else -> Progress.Idle
val cardDavProgress: AccountsModel.Progress = when {
cardDavRefreshing || cardDavSyncing -> AccountsModel.Progress.Active
cardDavSyncPending -> AccountsModel.Progress.Pending
else -> AccountsModel.Progress.Idle
}
val addressBooks by model.addressBooksPager.observeAsState()
@ -151,10 +145,10 @@ class AccountActivity : AppCompatActivity() {
val calDavRefreshing by model.calDavRefreshing.observeAsState(false)
val calDavSyncPending by model.calDavSyncPending.observeAsState(false)
val calDavSyncing by model.calDavSyncing.observeAsState(false)
val calDavProgress: Progress = when {
calDavRefreshing || calDavSyncing -> Progress.Active
calDavSyncPending -> Progress.Pending
else -> Progress.Idle
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()
@ -255,12 +249,12 @@ fun AccountOverview(
onSetShowOnlyPersonal: (showOnlyPersonal: Boolean) -> Unit,
hasCardDav: Boolean,
canCreateAddressBook: Boolean,
cardDavProgress: AccountActivity.Progress,
cardDavProgress: AccountsModel.Progress,
cardDavRefreshing: Boolean,
addressBooks: LazyPagingItems<Collection>?,
hasCalDav: Boolean,
canCreateCalendar: Boolean,
calDavProgress: AccountActivity.Progress,
calDavProgress: AccountsModel.Progress,
calDavRefreshing: Boolean,
calendars: LazyPagingItems<Collection>?,
subscriptions: LazyPagingItems<Collection>?,
@ -500,12 +494,12 @@ fun AccountOverview_CardDAV_CalDAV() {
onSetShowOnlyPersonal = {},
hasCardDav = true,
canCreateAddressBook = false,
cardDavProgress = AccountActivity.Progress.Active,
cardDavProgress = AccountsModel.Progress.Active,
cardDavRefreshing = false,
addressBooks = null,
hasCalDav = true,
canCreateCalendar = true,
calDavProgress = AccountActivity.Progress.Pending,
calDavProgress = AccountsModel.Progress.Pending,
calDavRefreshing = false,
calendars = null,
subscriptions = null
@ -695,7 +689,7 @@ fun DeleteAccountDialog(
@Composable
fun ServiceTab(
requiredPermissions: List<String>,
progress: AccountActivity.Progress,
progress: AccountsModel.Progress,
collections: LazyPagingItems<Collection>?,
onUpdateCollectionSync: (collectionId: Long, sync: Boolean) -> Unit = { _, _ -> },
onChangeForceReadOnly: (collectionId: Long, forceReadOnly: Boolean) -> Unit = { _, _ -> },
@ -707,14 +701,14 @@ fun ServiceTab(
// progress indicator
val progressAlpha = progressAlpha(progress)
when (progress) {
AccountActivity.Progress.Active -> LinearProgressIndicator(
AccountsModel.Progress.Active -> LinearProgressIndicator(
color = MaterialTheme.colors.secondary,
modifier = Modifier
.alpha(progressAlpha)
.fillMaxWidth()
)
AccountActivity.Progress.Pending,
AccountActivity.Progress.Idle -> LinearProgressIndicator(
AccountsModel.Progress.Pending,
AccountsModel.Progress.Idle -> LinearProgressIndicator(
color = MaterialTheme.colors.secondary,
progress = 1f,
modifier = Modifier
@ -750,12 +744,12 @@ fun ServiceTab(
}
@Composable
fun progressAlpha(progress: AccountActivity.Progress): Float {
fun progressAlpha(progress: AccountsModel.Progress): Float {
val progressAlpha by animateFloatAsState(
when (progress) {
AccountActivity.Progress.Active -> 1f
AccountActivity.Progress.Pending -> 0.5f
AccountActivity.Progress.Idle -> 0f
AccountsModel.Progress.Active -> 1f
AccountsModel.Progress.Pending -> 0.5f
AccountsModel.Progress.Idle -> 0f
},
label = "progressAlpha",
animationSpec = tween(500)

View file

@ -268,7 +268,7 @@ class WifiPermissionsActivity: AppCompatActivity() {
private val locationManager = context.getSystemService<LocationManager>()!!
val locationEnabled = broadcastReceiverFlow(context, IntentFilter(LocationManager.MODE_CHANGED_ACTION))
val locationEnabled = broadcastReceiverFlow(context, IntentFilter(LocationManager.MODE_CHANGED_ACTION), immediate = true)
.map { LocationManagerCompat.isLocationEnabled(locationManager) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false)

View file

@ -8,14 +8,14 @@ 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.ProvideTextStyle
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.NotificationAdd
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -32,7 +32,6 @@ fun ActionCard(
content: @Composable () -> Unit
) {
Card(Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
.fillMaxWidth()
.then(modifier)
) {
@ -40,12 +39,15 @@ fun ActionCard(
.padding(top = 8.dp, start = 8.dp, end = 8.dp)
.fillMaxWidth(),
) {
ProvideTextStyle(MaterialTheme.typography.body1) {
ProvideTextStyle(MaterialTheme.typography.bodyLarge) {
if (icon != null)
Row {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Icon(icon, "", Modifier
.align(Alignment.CenterVertically)
.padding(end = 8.dp))
.padding(8.dp))
content()
}
else
@ -53,11 +55,11 @@ fun ActionCard(
}
if (actionText != null)
TextButton(onClick = onAction) {
Text(
actionText.uppercase(),
style = MaterialTheme.typography.button
)
OutlinedButton(
onClick = onAction,
modifier = Modifier.padding(vertical = 8.dp)
) {
Text(actionText)
}
}
}

View file

@ -186,7 +186,7 @@ class BatteryOptimizationsPage: IntroPage {
init {
viewModelScope.launch {
broadcastReceiverFlow(context, IntentFilter(PermissionUtils.ACTION_POWER_SAVE_WHITELIST_CHANGED)).collect {
broadcastReceiverFlow(context, IntentFilter(PermissionUtils.ACTION_POWER_SAVE_WHITELIST_CHANGED), immediate = true).collect {
checkBatteryOptimizations()
}
}

View file

@ -27,7 +27,7 @@ fun broadcastReceiverFlow(
context: Context,
filter: IntentFilter,
flags: Int? = null,
immediate: Boolean = true
immediate: Boolean
): Flow<Intent> = callbackFlow {
val receiver = object: BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {