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 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,