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 androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.fabledsword.thoughtsync.core.ThoughtSync
|
import com.fabledsword.thoughtsync.core.ThoughtSync
|
||||||
import com.fabledsword.thoughtsync.ui.BoardScreen
|
import com.fabledsword.thoughtsync.ui.BoardScreen
|
||||||
|
import com.fabledsword.thoughtsync.ui.BoardSync
|
||||||
import com.fabledsword.thoughtsync.ui.BoardViewModel
|
import com.fabledsword.thoughtsync.ui.BoardViewModel
|
||||||
import com.fabledsword.thoughtsync.ui.ComposeSheet
|
import com.fabledsword.thoughtsync.ui.ComposeSheet
|
||||||
import com.fabledsword.thoughtsync.ui.NoteEditorScreen
|
import com.fabledsword.thoughtsync.ui.NoteEditorScreen
|
||||||
@@ -111,7 +112,17 @@ private fun App(core: ThoughtSync) {
|
|||||||
state = board.state,
|
state = board.state,
|
||||||
onOpen = board::open,
|
onOpen = board::open,
|
||||||
onOpenNote = board::openNote,
|
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 },
|
onOpenSync = { showingSync = true },
|
||||||
onSearch = board::search,
|
onSearch = board::search,
|
||||||
onCompose = { composing = true },
|
onCompose = { composing = true },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.fabledsword.thoughtsync.ui
|
package com.fabledsword.thoughtsync.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
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.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
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.LazyVerticalStaggeredGrid
|
||||||
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
|
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
|
||||||
import androidx.compose.foundation.lazy.staggeredgrid.items
|
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.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
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.material3.rememberDrawerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
@@ -56,13 +61,7 @@ fun BoardScreen(
|
|||||||
state: BoardState,
|
state: BoardState,
|
||||||
onOpen: (Destination) -> Unit,
|
onOpen: (Destination) -> Unit,
|
||||||
onOpenNote: (Note) -> Unit,
|
onOpenNote: (Note) -> Unit,
|
||||||
/**
|
sync: BoardSync,
|
||||||
* 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?,
|
|
||||||
onOpenSync: () -> Unit,
|
onOpenSync: () -> Unit,
|
||||||
onSearch: (String) -> Unit,
|
onSearch: (String) -> Unit,
|
||||||
onCompose: () -> Unit,
|
onCompose: () -> Unit,
|
||||||
@@ -77,7 +76,7 @@ fun BoardScreen(
|
|||||||
NavigationDrawer(
|
NavigationDrawer(
|
||||||
current = state.destination,
|
current = state.destination,
|
||||||
labels = state.labels,
|
labels = state.labels,
|
||||||
syncSummary = syncSummary,
|
syncSummary = sync.summary,
|
||||||
onOpen = {
|
onOpen = {
|
||||||
onOpen(it)
|
onOpen(it)
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
@@ -112,17 +111,73 @@ fun BoardScreen(
|
|||||||
state.error?.let { message ->
|
state.error?.let { message ->
|
||||||
ErrorBanner(message = message, onDismiss = onDismissError)
|
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 {
|
val pull = rememberPullToRefreshState()
|
||||||
state.loading -> LoadingBoard()
|
Box(
|
||||||
state.notes.isEmpty() -> EmptyBoard(state)
|
modifier =
|
||||||
else -> NoteBoard(notes = state.notes, onOpenNote = onOpenNote)
|
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
|
* A search field IS the top bar, following the phone convention rather than the
|
||||||
* desktop's title-plus-sidebar.
|
* 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)
|
stringResource(R.string.board_empty_title) to stringResource(R.string.board_empty_body)
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
// A LazyColumn holding one centred item, NOT a plain Column. Pull-to-refresh
|
||||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
// 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,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
item {
|
||||||
Spacer(Modifier.height(8.dp))
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text(
|
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
||||||
text = body,
|
Spacer(Modifier.height(8.dp))
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
Text(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
text = body,
|
||||||
)
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -278,6 +278,18 @@ class SyncViewModel(
|
|||||||
state = state.copy(lastRevoke = null)
|
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 {
|
companion object {
|
||||||
fun factory(
|
fun factory(
|
||||||
core: ThoughtSync,
|
core: ThoughtSync,
|
||||||
|
|||||||
Reference in New Issue
Block a user