M12 — the Android client, end to end #2

Merged
bvandeusen merged 86 commits from dev into main 2026-08-21 08:53:58 -04:00
3 changed files with 109 additions and 22 deletions
Showing only changes of commit 64542ed6cb - Show all commits
@@ -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,16 +111,72 @@ 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)
}
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 { when {
state.loading -> LoadingBoard() state.loading -> LoadingBoard()
state.notes.isEmpty() -> EmptyBoard(state) state.notes.isEmpty() -> EmptyBoard(state)
else -> NoteBoard(notes = state.notes, onOpenNote = onOpenNote) 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
@@ -303,11 +358,18 @@ 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,
) { ) {
item {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = title, style = MaterialTheme.typography.titleMedium) Text(text = title, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
@@ -317,6 +379,8 @@ private fun EmptyBoard(state: BoardState) {
) )
} }
} }
}
}
@Composable @Composable
private fun LoadingBoard() { private fun LoadingBoard() {
@@ -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,