fix(#240): access sheet waits for the first read, manager re-reads access, shared account row

This commit is contained in:
makiolaj committed 2026-11-14 01:02:58 +01:00
1 parent 34e8e8571c
commit 6161efab40
6 files changed
+77 -40

No files matched your search

@@ -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() {