fix(#240): access sheet waits for the first read, manager re-reads access, shared account row
This commit is contained in:
1 parent
34e8e8571c
commit
6161efab40
6 files changed
+77
-40
No files matched your search
+14
-3
@@ -183,7 +183,11 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
{ listOf(original?.calendarId ?: dataSource.eventCalendarId(eventId), *others) }
|
||||
|
||||
private fun targetsOf(calendars: List<CalendarSource>, dismissed: Set<SyncAccount>): SyncTargets =
|
||||
reach(calendars.syncAccounts().withAdapter()).let {
|
||||
reach(
|
||||
calendars.syncAccounts().withAdapter()
|
||||
.sortedWith(compareBy({ it.name.lowercase() }, { it.type }))
|
||||
.toSet(),
|
||||
).let {
|
||||
if (dismissed.isEmpty()) {
|
||||
it
|
||||
} else {
|
||||
@@ -196,8 +200,15 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
|
||||
override val syncAccessDismissed: Flow<Set<SyncAccount>> = prefs.syncAccessDismissed
|
||||
|
||||
override suspend fun setSyncAccessDismissed(account: SyncAccount, dismissed: Boolean) =
|
||||
prefs.setSyncAccessDismissed(account, dismissed)
|
||||
override suspend fun setSyncAccessDismissed(account: SyncAccount, dismissed: Boolean) {
|
||||
try {
|
||||
prefs.setSyncAccessDismissed(account, dismissed)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Sync access dismissal not saved", e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun requestSync(): SyncRequestResult = withContext(io) {
|
||||
val targets = try {
|
||||
|
||||
@@ -57,6 +57,7 @@ import de.jeanlucmakiola.calendula.ui.common.DrawerSync
|
||||
import de.jeanlucmakiola.calendula.ui.common.SyncViewModel
|
||||
import de.jeanlucmakiola.calendula.ui.common.RefreshSyncAccessOnOpen
|
||||
import de.jeanlucmakiola.calendula.ui.common.SyncAccessSheet
|
||||
import de.jeanlucmakiola.calendula.data.calendar.SyncTargets
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberSyncAccessPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.messageRes
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFab
|
||||
@@ -450,7 +451,8 @@ fun CalendarHost(
|
||||
val selectionFailure by eventActions.failure.collectAsStateWithLifecycle()
|
||||
// "Sync now" in the drawer (#240); its receipt shares the move chip.
|
||||
val sync: SyncViewModel = hiltViewModel()
|
||||
val syncTargets by sync.targets.collectAsStateWithLifecycle()
|
||||
val loadedSyncTargets by sync.targets.collectAsStateWithLifecycle()
|
||||
val syncTargets = loadedSyncTargets ?: SyncTargets.None
|
||||
val syncInFlight by sync.inFlight.collectAsStateWithLifecycle()
|
||||
val syncResult by sync.result.collectAsStateWithLifecycle()
|
||||
val selectionFailureMessage = eventActionFailureMessage(selectionFailure)
|
||||
@@ -718,7 +720,7 @@ fun CalendarHost(
|
||||
|
||||
if (showSyncAccess) {
|
||||
SyncAccessSheet(
|
||||
accounts = syncTargets.needsAccess.toList(),
|
||||
accounts = loadedSyncTargets?.needsAccess?.toList(),
|
||||
onGrant = launchAccessPicker,
|
||||
onDismissAccount = sync::dismissAccess,
|
||||
onDismiss = { showSyncAccess = false },
|
||||
|
||||
@@ -86,7 +86,9 @@ import de.jeanlucmakiola.calendula.ui.common.orUnnamed
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||
import de.jeanlucmakiola.calendula.data.calendar.SyncAccount
|
||||
import de.jeanlucmakiola.calendula.ui.common.sourceAppName
|
||||
import de.jeanlucmakiola.calendula.ui.common.SyncAccountRow
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||
@@ -134,6 +136,7 @@ fun CalendarsScreen(
|
||||
val deleteLockedIds by viewModel.deleteLockedCalendarIds.collectAsStateWithLifecycle()
|
||||
val error by viewModel.error.collectAsStateWithLifecycle()
|
||||
val syncAccessDismissed by viewModel.syncAccessDismissed.collectAsStateWithLifecycle()
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.refreshAccess() }
|
||||
|
||||
// null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar.
|
||||
// [editorSession] bumps on every open so the editor's field state resets for
|
||||
@@ -335,16 +338,11 @@ private fun CalendarsList(
|
||||
GroupedSectionHeader(stringResource(R.string.calendars_sync_access_header))
|
||||
GroupedHint(stringResource(R.string.calendars_sync_access_hint), style = MaterialTheme.typography.bodySmall)
|
||||
syncAccessDismissed.forEachIndexed { index, account ->
|
||||
GroupedRow(
|
||||
title = account.name,
|
||||
summary = sourceAppName(account.type),
|
||||
SyncAccountRow(
|
||||
account = account,
|
||||
position = positionOf(index, syncAccessDismissed.size),
|
||||
leading = { SourceLogo(account.type) },
|
||||
trailing = {
|
||||
TextButton(onClick = { onAskSyncAccessAgain(account) }) {
|
||||
Text(stringResource(R.string.calendars_sync_access_ask_again))
|
||||
}
|
||||
},
|
||||
actionLabel = stringResource(R.string.calendars_sync_access_ask_again),
|
||||
onAction = { onAskSyncAccessAgain(account) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import de.jeanlucmakiola.calendula.data.calendar.SyncAccount
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||
@@ -57,16 +61,27 @@ class CalendarsViewModel @Inject constructor(
|
||||
initialValue = emptyList(),
|
||||
)
|
||||
|
||||
private val accessChecks = MutableStateFlow(0)
|
||||
|
||||
/**
|
||||
* Accounts the drawer was told not to ask sync-request access for (#240),
|
||||
* limited to those still behind a visible calendar and still unreachable.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val syncAccessDismissed: StateFlow<List<SyncAccount>> =
|
||||
repository.syncTargets
|
||||
.map { targets -> targets.dismissed.sortedWith(compareBy({ it.name.lowercase() }, { it.type })) }
|
||||
combine(repository.syncAccessDismissed, accessChecks) { stored, _ -> stored }
|
||||
.flatMapLatest { stored ->
|
||||
if (stored.isEmpty()) flowOf(emptyList())
|
||||
else repository.syncTargets.map { it.dismissed.toList() }
|
||||
}
|
||||
.catch { emit(emptyList()) }
|
||||
.stateWhileSubscribed(scope = viewModelScope, initialValue = emptyList())
|
||||
|
||||
/** Re-read account access, which can change outside the app; called on resume. */
|
||||
fun refreshAccess() {
|
||||
accessChecks.update { it + 1 }
|
||||
}
|
||||
|
||||
fun askSyncAccessAgain(account: SyncAccount) {
|
||||
viewModelScope.launch { repository.setSyncAccessDismissed(account, dismissed = false) }
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -25,6 +24,7 @@ import de.jeanlucmakiola.calendula.data.calendar.accountAccessIntent
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
|
||||
/**
|
||||
@@ -69,32 +69,42 @@ fun RefreshSyncAccessOnOpen(drawerState: DrawerState, sync: SyncViewModel) {
|
||||
*/
|
||||
@Composable
|
||||
fun SyncAccessSheet(
|
||||
accounts: List<SyncAccount>,
|
||||
accounts: List<SyncAccount>?,
|
||||
onGrant: (SyncAccount) -> Unit,
|
||||
onDismissAccount: (SyncAccount) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// Closes once the last account is granted or dismissed, not while the first read is pending.
|
||||
var hadAccounts by rememberSaveable { mutableStateOf(false) }
|
||||
LaunchedEffect(accounts.isEmpty()) {
|
||||
if (accounts.isNotEmpty()) hadAccounts = true else if (hadAccounts) onDismiss()
|
||||
}
|
||||
LaunchedEffect(accounts?.isEmpty()) { if (accounts?.isEmpty() == true) onDismiss() }
|
||||
FullScreenPicker(title = stringResource(R.string.drawer_sync_access), onDismiss = onDismiss, predictiveBack = true) {
|
||||
GroupedHint(stringResource(R.string.sync_access_hint), verticalPadding = 8.dp)
|
||||
accounts.forEachIndexed { index, account ->
|
||||
GroupedRow(
|
||||
title = account.name,
|
||||
summary = sourceAppName(account.type),
|
||||
position = positionOf(index, accounts.size),
|
||||
minHeight = 64.dp,
|
||||
leading = { SourceLogo(account.type) },
|
||||
trailing = {
|
||||
TextButton(onClick = { onDismissAccount(account) }) {
|
||||
Text(stringResource(R.string.sync_access_dont_ask))
|
||||
}
|
||||
},
|
||||
accounts.orEmpty().forEachIndexed { index, account ->
|
||||
SyncAccountRow(
|
||||
account = account,
|
||||
position = positionOf(index, accounts.orEmpty().size),
|
||||
actionLabel = stringResource(R.string.sync_access_dont_ask),
|
||||
onAction = { onDismissAccount(account) },
|
||||
onClick = { onGrant(account) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One synced account with a text action, as the access sheet and the calendar manager list them. */
|
||||
@Composable
|
||||
fun SyncAccountRow(
|
||||
account: SyncAccount,
|
||||
position: Position,
|
||||
actionLabel: String,
|
||||
onAction: () -> Unit,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = account.name,
|
||||
summary = sourceAppName(account.type),
|
||||
position = position,
|
||||
minHeight = 64.dp,
|
||||
leading = { SourceLogo(account.type) },
|
||||
trailing = { TextButton(onClick = onAction) { Text(actionLabel) } },
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
@@ -38,13 +38,14 @@ class SyncViewModel @Inject constructor(
|
||||
private val accessChecks = MutableStateFlow(0)
|
||||
|
||||
/**
|
||||
* Who "Sync now" can reach, and who first needs the user's grant. Account
|
||||
* access isn't observable, so it is re-read on every [refreshAccess].
|
||||
* Who "Sync now" can reach, and who first needs the user's grant; null until
|
||||
* first read. Account access isn't observable, so it is re-read on every
|
||||
* [refreshAccess].
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val targets: StateFlow<SyncTargets> = accessChecks
|
||||
val targets: StateFlow<SyncTargets?> = accessChecks
|
||||
.flatMapLatest { repository.syncTargets.catch { emit(SyncTargets.None) } }
|
||||
.stateWhileSubscribed(scope = viewModelScope, initialValue = SyncTargets.None)
|
||||
.stateWhileSubscribed(scope = viewModelScope, initialValue = null)
|
||||
|
||||
/** Re-read account access: whenever the app resumes, which includes the picker returning. */
|
||||
fun refreshAccess() {
|
||||
|
||||
Reference in new issue
Block a user