From 64542ed6cb9fa0c77c82e2c042ffcd3789ab1e17 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 19 Aug 2026 16:36:56 -0400 Subject: [PATCH] android: pull the board down to sync (M12 step 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../fabledsword/thoughtsync/MainActivity.kt | 13 ++- .../fabledsword/thoughtsync/ui/BoardScreen.kt | 106 ++++++++++++++---- .../thoughtsync/ui/SyncViewModel.kt | 12 ++ 3 files changed, 109 insertions(+), 22 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt index 52d10a6..d943db0 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt @@ -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 }, diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt index c490a6f..a934ce2 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardScreen.kt @@ -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, + ) + } + } } } diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/SyncViewModel.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/SyncViewModel.kt index 776af1b..efa6488 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/SyncViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/SyncViewModel.kt @@ -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,