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