diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt index 98872219..846b1f96 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt @@ -183,7 +183,11 @@ class CalendarRepositoryImpl @Inject constructor( { listOf(original?.calendarId ?: dataSource.eventCalendarId(eventId), *others) } private fun targetsOf(calendars: List, dismissed: Set): 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> = 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 { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index 22b75422..230881e2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -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 }, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt index d215fa90..e02ccfdb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt @@ -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) }, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt index 131fd970..2f4ab300 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt @@ -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> = - 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) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/SyncAccessPicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/SyncAccessPicker.kt index db705450..eb3f82b4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/SyncAccessPicker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/SyncAccessPicker.kt @@ -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, + accounts: List?, 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, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/SyncViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/SyncViewModel.kt index f3ff3f0e..00b2f3b1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/SyncViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/SyncViewModel.kt @@ -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 = accessChecks + val targets: StateFlow = 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() {