android: pull the board down to sync (M12 step 6)
Every sync so far has been a button press on a screen you have to navigate to. On a phone the gesture for "check if there's anything new" is a pull, and not having it is the kind of absence people read as the app not syncing at all. **The gesture is INERT when this device has no server.** `Modifier.pullToRefresh` takes an `enabled`, which is why the modifier and the indicator are wired by hand instead of using `PullToRefreshBox` — that wrapper is less code and offers no way to turn the gesture off. An unlinked device has nowhere to pull from, and a gesture that always comes back empty is how people learn a control is broken. Same reasoning as the drawer badge staying silent when unlinked: local-only is this app's resting state, not a fault. **A failed refresh reaches the board.** Otherwise the spinner retracts and nothing happens, which is indistinguishable from "you were already up to date" — the one outcome it must not be confused with. It renders as a second banner rather than replacing the store-error one: those are different facts about different halves of the app, and hiding either behind the other reports the wrong problem. Dismissing is honest — the note is still pending, `hasPending` still says so, and the next cycle reports the same fault if it persists. **The empty board is now a `LazyColumn` holding one centred item.** Pull-to- refresh works through nested scroll, and a layout that never scrolls never dispatches any, so on the old plain `Column` the gesture would have been dead on exactly the screen where it matters most: linked, board empty, notes still on the server. Looks identical. The five sync facts the board needs arrive as one `BoardSync` rather than five parameters, for the reason `EditorAction` exists: `summary` and `error` are both `String?` and both about sync, so positionally they could be swapped with nothing to catch it. Still no automatic sync — no background cycle, no sync-on-resume. This is a faster way to ask, not a decision to stop asking. TalkBack users cannot perform a pull; the drawer's Sync → Sync now remains the accessible path, unchanged. Verified against the real artifact rather than from memory, since `material3` resolves through the BOM: 1.4.0's sources confirm `pullToRefresh` has `enabled`, and that none of `pullToRefresh`, `rememberPullToRefreshState`, `Indicator` or `PullToRefreshBox` is `@ExperimentalMaterial3Api` there — only two deprecated members are. So no `@OptIn`, which is what keeps the build at zero warnings.
This commit is contained in:
@@ -14,6 +14,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||
import com.fabledsword.thoughtsync.ui.BoardScreen
|
||||
import com.fabledsword.thoughtsync.ui.BoardSync
|
||||
import com.fabledsword.thoughtsync.ui.BoardViewModel
|
||||
import com.fabledsword.thoughtsync.ui.ComposeSheet
|
||||
import com.fabledsword.thoughtsync.ui.NoteEditorScreen
|
||||
@@ -111,7 +112,17 @@ private fun App(core: ThoughtSync) {
|
||||
state = board.state,
|
||||
onOpen = board::open,
|
||||
onOpenNote = board::openNote,
|
||||
syncSummary = syncSummary(sync),
|
||||
sync =
|
||||
BoardSync(
|
||||
summary = syncSummary(sync),
|
||||
error = sync.state.syncError,
|
||||
refreshing = sync.state.syncing,
|
||||
// Only a linked device has anywhere to pull FROM. The
|
||||
// board never learns this itself — one owner for the fact.
|
||||
canRefresh = sync.state.linked,
|
||||
onRefresh = sync::syncNow,
|
||||
onDismissError = sync::dismissSyncError,
|
||||
),
|
||||
onOpenSync = { showingSync = true },
|
||||
onSearch = board::search,
|
||||
onCompose = { composing = true },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -11,6 +12,7 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.items
|
||||
@@ -38,6 +40,9 @@ import androidx.compose.material3.NavigationDrawerItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
|
||||
import androidx.compose.material3.pulltorefresh.pullToRefresh
|
||||
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -56,13 +61,7 @@ fun BoardScreen(
|
||||
state: BoardState,
|
||||
onOpen: (Destination) -> Unit,
|
||||
onOpenNote: (Note) -> Unit,
|
||||
/**
|
||||
* One line of sync state for the drawer, or null when there is nothing worth
|
||||
* saying. Passed in rather than read from [BoardState]: sync has its own view
|
||||
* model, and giving the board a copy of it would be two sources of truth for
|
||||
* whether this device is linked.
|
||||
*/
|
||||
syncSummary: String?,
|
||||
sync: BoardSync,
|
||||
onOpenSync: () -> Unit,
|
||||
onSearch: (String) -> Unit,
|
||||
onCompose: () -> Unit,
|
||||
@@ -77,7 +76,7 @@ fun BoardScreen(
|
||||
NavigationDrawer(
|
||||
current = state.destination,
|
||||
labels = state.labels,
|
||||
syncSummary = syncSummary,
|
||||
syncSummary = sync.summary,
|
||||
onOpen = {
|
||||
onOpen(it)
|
||||
scope.launch { drawerState.close() }
|
||||
@@ -112,17 +111,73 @@ fun BoardScreen(
|
||||
state.error?.let { message ->
|
||||
ErrorBanner(message = message, onDismiss = onDismissError)
|
||||
}
|
||||
// Stacked rather than one-or-the-other. A store failure and a sync
|
||||
// failure are different facts about different halves of the app;
|
||||
// hiding either behind the other would report the wrong problem.
|
||||
sync.error?.let { message ->
|
||||
ErrorBanner(message = message, onDismiss = sync.onDismissError)
|
||||
}
|
||||
|
||||
when {
|
||||
state.loading -> LoadingBoard()
|
||||
state.notes.isEmpty() -> EmptyBoard(state)
|
||||
else -> NoteBoard(notes = state.notes, onOpenNote = onOpenNote)
|
||||
val pull = rememberPullToRefreshState()
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.pullToRefresh(
|
||||
isRefreshing = sync.refreshing,
|
||||
state = pull,
|
||||
// INERT on a device with no server, rather than
|
||||
// spinning and finding nothing: there is no remote
|
||||
// to fetch from, and a gesture that always comes
|
||||
// back empty teaches people it is broken.
|
||||
enabled = sync.canRefresh && !state.loading,
|
||||
onRefresh = sync.onRefresh,
|
||||
),
|
||||
) {
|
||||
when {
|
||||
state.loading -> LoadingBoard()
|
||||
state.notes.isEmpty() -> EmptyBoard(state)
|
||||
else -> NoteBoard(notes = state.notes, onOpenNote = onOpenNote)
|
||||
}
|
||||
// `PullToRefreshBox` would be less code, but it takes no
|
||||
// `enabled`, so the modifier and the indicator are wired by
|
||||
// hand to keep the gate above.
|
||||
PullToRefreshDefaults.Indicator(
|
||||
state = pull,
|
||||
isRefreshing = sync.refreshing,
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the board knows about sync, which is deliberately not much.
|
||||
*
|
||||
* A holder rather than six loose parameters, for the reason `EditorAction`
|
||||
* exists: [summary] and [error] are both `String?` and both about sync, so as
|
||||
* positional arguments they could be swapped with nothing to catch it. Named
|
||||
* fields make that unsayable.
|
||||
*
|
||||
* Passed in rather than read from [BoardState]. Sync has its own view model, and
|
||||
* giving the board a second copy of "is this device linked" would be two sources
|
||||
* of truth for one fact.
|
||||
*/
|
||||
data class BoardSync(
|
||||
/** One line for the drawer badge, or null when there is nothing worth saying. */
|
||||
val summary: String?,
|
||||
/** The last sync failure, still unacknowledged. */
|
||||
val error: String?,
|
||||
/** A sync is in flight — drives the indicator, whoever started it. */
|
||||
val refreshing: Boolean,
|
||||
/** Whether there is a server to refresh FROM. False means the gesture is off. */
|
||||
val canRefresh: Boolean,
|
||||
val onRefresh: () -> Unit,
|
||||
val onDismissError: () -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* A search field IS the top bar, following the phone convention rather than the
|
||||
* desktop's title-plus-sidebar.
|
||||
@@ -303,18 +358,27 @@ private fun EmptyBoard(state: BoardState) {
|
||||
stringResource(R.string.board_empty_title) to stringResource(R.string.board_empty_body)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
// A LazyColumn holding one centred item, NOT a plain Column. Pull-to-refresh
|
||||
// works through nested scroll, and a layout that never scrolls never dispatches
|
||||
// any — so on a Column the gesture would be dead on exactly the screen where it
|
||||
// matters most: linked, board empty, notes still on the server.
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = body,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
item {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = body,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -278,6 +278,18 @@ class SyncViewModel(
|
||||
state = state.copy(lastRevoke = null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Acknowledge a sync failure.
|
||||
*
|
||||
* Exists because the BOARD reports these too, and a banner the person cannot
|
||||
* get rid of is worse than the failure it describes. Clearing is honest here:
|
||||
* an unsynced note is still pending, `hasPending` still says so, and the next
|
||||
* cycle will report the same fault if it is still there.
|
||||
*/
|
||||
fun dismissSyncError() {
|
||||
state = state.copy(syncError = null)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun factory(
|
||||
core: ThoughtSync,
|
||||
|
||||
Reference in New Issue
Block a user