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,