Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
061e85ddcd | ||
|
|
172591901a |
@@ -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
|
||||
|
||||
@@ -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<Boolean> =
|
||||
combine(availability.observe(), accounts.observeAll()) { usable, all -> usable && all.isNotEmpty() }
|
||||
|
||||
private val _syncing = MutableStateFlow(false)
|
||||
override val syncing: StateFlow<Boolean> = _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<String>) {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<Boolean>
|
||||
|
||||
/** True from [syncAll] until the syncs it started have finished. */
|
||||
val syncing: StateFlow<Boolean>
|
||||
|
||||
fun syncAll()
|
||||
|
||||
object None : ManualSync {
|
||||
override val available: Flow<Boolean> = flowOf(false)
|
||||
override val syncing: StateFlow<Boolean> = MutableStateFlow(false)
|
||||
override fun syncAll() = Unit
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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<Boolean> =
|
||||
manualSync.available.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||
val syncing: StateFlow<Boolean> = manualSync.syncing
|
||||
|
||||
fun syncAll() = manualSync.syncAll()
|
||||
|
||||
/**
|
||||
* Where a new list may go: the device, then every account whose server said
|
||||
* it would take one.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Boolean> =
|
||||
manualSync.available.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||
val syncing: StateFlow<Boolean> = manualSync.syncing
|
||||
|
||||
fun syncAll() = manualSync.syncAll()
|
||||
|
||||
private val filter = MutableStateFlow<TaskFilter?>(null)
|
||||
|
||||
private val tagFilter = MutableStateFlow<Set<String>>(emptySet())
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user