diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt index ac27212..f863553 100644 --- a/app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt @@ -15,8 +15,10 @@ import de.jeanlucmakiola.agendula.data.sync.AccountCreator import de.jeanlucmakiola.agendula.data.sync.AccountRepository import de.jeanlucmakiola.agendula.data.sync.CalDavGateway import de.jeanlucmakiola.agendula.data.sync.LoginFlowRecord +import de.jeanlucmakiola.agendula.data.sync.ManualSync import de.jeanlucmakiola.agendula.data.sync.OkHttpCalDavGateway import de.jeanlucmakiola.agendula.data.sync.PendingLoginFlowStore +import de.jeanlucmakiola.agendula.data.sync.PullSync import de.jeanlucmakiola.agendula.data.sync.RemoteListRepository import de.jeanlucmakiola.agendula.data.sync.RemoteLists import de.jeanlucmakiola.agendula.data.sync.SyncNoticeNotifier @@ -81,6 +83,10 @@ abstract class SyncBindModule { @Singleton abstract fun bindSyncRequests(impl: SyncTrigger): SyncRequests + @Binds + @Singleton + abstract fun bindManualSync(impl: PullSync): ManualSync + @Binds @IntoSet abstract fun bindSyncNoticeChannel(impl: SyncNoticeNotifier): ChannelRefresher diff --git a/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/PullSync.kt b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/PullSync.kt new file mode 100644 index 0000000..3e1f1fc --- /dev/null +++ b/app/src/full/java/de/jeanlucmakiola/agendula/data/sync/PullSync.kt @@ -0,0 +1,76 @@ +package de.jeanlucmakiola.agendula.data.sync + +import android.content.Context +import androidx.work.WorkInfo +import androidx.work.WorkManager +import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.agendula.data.di.ApplicationScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +/** + * Pull to sync over [SyncTrigger]: an expedited sync per account, and + * [syncing] held until they finish. + * + * A sync waiting on its network constraint never starts, so if none is running + * after [START_GRACE] the indicator lets go; the work stays queued and runs + * once the device is back online. + */ +@Singleton +class PullSync @Inject constructor( + @ApplicationContext private val context: Context, + private val accounts: AccountRepository, + private val trigger: SyncTrigger, + availability: SyncAvailability, + @ApplicationScope private val scope: CoroutineScope, +) : ManualSync { + + override val available: Flow = + combine(availability.observe(), accounts.observeAll()) { usable, all -> usable && all.isNotEmpty() } + + private val _syncing = MutableStateFlow(false) + override val syncing: StateFlow = _syncing + + override fun syncAll() { + if (_syncing.value) return + _syncing.value = true + scope.launch { + try { + val names = accounts.syncable().map { trigger.enqueue(it.displayName, expedited = true) } + if (names.isNotEmpty()) awaitSyncs(names) + } finally { + _syncing.value = false + } + } + } + + private suspend fun awaitSyncs(names: List) { + val workManager = WorkManager.getInstance(context) + val states = combine(names.map { workManager.getWorkInfosForUniqueWorkFlow(it) }) { infos -> + infos.flatMap { it }.map { it.state } + } + withTimeoutOrNull(MAX_WAIT) { + val started = withTimeoutOrNull(START_GRACE) { + states.first { all -> WorkInfo.State.RUNNING in all || all.all { it.isFinished } } + } ?: return@withTimeoutOrNull + if (started.all { it.isFinished }) return@withTimeoutOrNull + states.map { all -> WorkInfo.State.RUNNING in all }.first { running -> !running } + } + } + + private companion object { + val START_GRACE = 3.seconds + val MAX_WAIT = 2.minutes + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/ManualSync.kt b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/ManualSync.kt new file mode 100644 index 0000000..0f3f89b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/data/sync/ManualSync.kt @@ -0,0 +1,23 @@ +package de.jeanlucmakiola.agendula.data.sync + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOf + +/** Pull to sync (#73): syncs every account at once. Inert in the `offline` flavor. */ +interface ManualSync { + /** Whether there is anything to sync: our own store, with at least one account. */ + val available: Flow + + /** True from [syncAll] until the syncs it started have finished. */ + val syncing: StateFlow + + fun syncAll() + + object None : ManualSync { + override val available: Flow = flowOf(false) + override val syncing: StateFlow = MutableStateFlow(false) + override fun syncAll() = Unit + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/SyncPullBox.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/SyncPullBox.kt new file mode 100644 index 0000000..efccbbf --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/common/SyncPullBox.kt @@ -0,0 +1,51 @@ +package de.jeanlucmakiola.agendula.ui.common + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Pull down to sync every account (#73). Without an account there is nothing + * to sync, so the gesture is off. [topPadding] keeps the indicator below a top + * bar the content scrolls under. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun SyncPullBox( + available: Boolean, + syncing: Boolean, + onSync: () -> Unit, + modifier: Modifier = Modifier, + topPadding: Dp = 0.dp, + content: @Composable BoxScope.() -> Unit, +) { + if (!available) { + Box(modifier, content = content) + return + } + val state = rememberPullToRefreshState() + PullToRefreshBox( + isRefreshing = syncing, + onRefresh = onSync, + modifier = modifier, + state = state, + indicator = { + PullToRefreshDefaults.LoadingIndicator( + state = state, + isRefreshing = syncing, + modifier = Modifier.align(Alignment.TopCenter).padding(top = topPadding), + ) + }, + content = content, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt index f64cd57..52906ad 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsScreen.kt @@ -97,6 +97,7 @@ import kotlinx.coroutines.delay import de.jeanlucmakiola.agendula.ui.common.ListColorChip import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton +import de.jeanlucmakiola.agendula.ui.common.SyncPullBox import de.jeanlucmakiola.agendula.ui.common.priorityAccent import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel import de.jeanlucmakiola.floret.components.positionOf @@ -122,6 +123,8 @@ fun ListsScreen( viewModel: ListsViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() + val syncAvailable by viewModel.syncAvailable.collectAsStateWithLifecycle() + val syncing by viewModel.syncing.collectAsStateWithLifecycle() var query by rememberSaveable { mutableStateOf("") } var searchActive by rememberSaveable { mutableStateOf(false) } var newList by rememberSaveable { mutableStateOf(false) } @@ -173,7 +176,12 @@ fun ListsScreen( } }, ) { inner -> - Box(Modifier.fillMaxSize().padding(top = inner.calculateTopPadding())) { + SyncPullBox( + available = syncAvailable && !searchActive, + syncing = syncing, + onSync = viewModel::syncAll, + modifier = Modifier.fillMaxSize().padding(top = inner.calculateTopPadding()), + ) { when (val s = state) { ListsUiState.Loading -> Unit // brief; avoids a flash before first emission ListsUiState.Failure -> diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt index 90f2c18..838203e 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/lists/ListsViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.agendula.data.sync.ManualSync import de.jeanlucmakiola.agendula.data.sync.RemoteLists import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.recoveringFromProviderFailure @@ -105,9 +106,17 @@ private const val UPCOMING_PREVIEW = 3 class ListsViewModel @Inject constructor( private val repository: TasksRepository, private val remoteLists: RemoteLists, + private val manualSync: ManualSync, settingsPrefs: SettingsPrefs, ) : ViewModel() { + /** Pull to sync (#73); off without an account. */ + val syncAvailable: StateFlow = + manualSync.available.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + val syncing: StateFlow = manualSync.syncing + + fun syncAll() = manualSync.syncAll() + /** * Where a new list may go: the device, then every account whose server said * it would take one. diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt index ee3a26a..d6b1bef 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListScreen.kt @@ -112,6 +112,7 @@ import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskSection import de.jeanlucmakiola.agendula.domain.TaskSections import de.jeanlucmakiola.agendula.ui.common.LocalUse24HourFormat +import de.jeanlucmakiola.agendula.ui.common.SyncPullBox import de.jeanlucmakiola.agendula.ui.common.ignoringEdgeSwipes import de.jeanlucmakiola.agendula.ui.common.priorityAccent import de.jeanlucmakiola.agendula.ui.lists.ListEditorSheet @@ -145,6 +146,8 @@ fun TaskListScreen( viewModel: TaskListViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() + val syncAvailable by viewModel.syncAvailable.collectAsStateWithLifecycle() + val syncing by viewModel.syncing.collectAsStateWithLifecycle() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val content = state as? TaskListUiState.Content val list = content?.list @@ -248,13 +251,21 @@ fun TaskListScreen( }, ) { inner -> Box(modifier = Modifier.fillMaxSize()) { - when (val s = state) { - TaskListUiState.Loading -> Unit // brief; avoids a flash before first emission - TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner) - is TaskListUiState.Content -> TaskListBody( - s, filter, inner, onOpenTask, onRequestDelete, viewModel, - onClearCompleted = if (listId != null && list?.isReadOnly != true) ({ confirmClear = true }) else null, - ) + SyncPullBox( + available = syncAvailable, + syncing = syncing, + onSync = viewModel::syncAll, + modifier = Modifier.fillMaxSize(), + topPadding = inner.calculateTopPadding(), + ) { + when (val s = state) { + TaskListUiState.Loading -> Unit // brief; avoids a flash before first emission + TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner) + is TaskListUiState.Content -> TaskListBody( + s, filter, inner, onOpenTask, onRequestDelete, viewModel, + onClearCompleted = if (listId != null && list?.isReadOnly != true) ({ confirmClear = true }) else null, + ) + } } // A FAB-height band anchored at the bottom-start with the FAB's own // margin; centring the chip in it lines the chip up beside the bottom-end diff --git a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt index 68b88f6..b74fece 100644 --- a/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/agendula/ui/tasklist/TaskListViewModel.kt @@ -21,6 +21,7 @@ import de.jeanlucmakiola.agendula.domain.comparator import de.jeanlucmakiola.agendula.ui.lists.ListWriteFailure import de.jeanlucmakiola.agendula.ui.lists.asFailure import de.jeanlucmakiola.agendula.data.di.ApplicationScope +import de.jeanlucmakiola.agendula.data.sync.ManualSync import de.jeanlucmakiola.agendula.domain.recurrence.RecurringScope import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -93,9 +94,17 @@ class TaskListViewModel @Inject constructor( private val repository: TasksRepository, private val settingsPrefs: SettingsPrefs, private val remoteLists: RemoteLists, + private val manualSync: ManualSync, @ApplicationScope private val appScope: CoroutineScope, ) : ViewModel() { + /** Pull to sync (#73); off without an account. */ + val syncAvailable: StateFlow = + manualSync.available.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + val syncing: StateFlow = manualSync.syncing + + fun syncAll() = manualSync.syncAll() + private val filter = MutableStateFlow(null) private val tagFilter = MutableStateFlow>(emptySet()) diff --git a/app/src/offline/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt b/app/src/offline/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt index 1a3d7fc..c796b4b 100644 --- a/app/src/offline/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt +++ b/app/src/offline/java/de/jeanlucmakiola/agendula/data/di/SyncModule.kt @@ -4,6 +4,7 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import de.jeanlucmakiola.agendula.data.sync.ManualSync import de.jeanlucmakiola.agendula.data.sync.RemoteLists import de.jeanlucmakiola.agendula.data.sync.RemoteLists.Outcome import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity @@ -26,6 +27,9 @@ object SyncModule { @Provides fun provideRemoteLists(): RemoteLists = NoRemoteLists + + @Provides + fun provideManualSync(): ManualSync = ManualSync.None } private object NoRemoteLists : RemoteLists {