android: pull the board down to sync (M12 step 6)
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m18s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m35s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Android / Kotlin + Rust (debug APK) (push) Successful in 7m56s

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:
2026-08-19 16:36:56 -04:00
parent 65d8f5f9c6
commit 64542ed6cb
3 changed files with 109 additions and 22 deletions
@@ -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,