commit 4a26c5bda68a7055f92e0484b64e38cc2cc3ecd9
parent 256e834ecf9fdc159a0f4cbdae0eaf82da41f606
Author: triesap <tyson@radroots.org>
Date: Tue, 11 Aug 2026 16:43:23 +0000
ui: implement bootstrap welcome and read-only entry
- replace the account-first root with the locked welcome canvas
- enter Today through an explicit session-local read-only choice
- route local create and import choices without business state
- omit unsupported remote-signer functionality
Diffstat:
5 files changed, 278 insertions(+), 33 deletions(-)
diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleApplication.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleApplication.kt
@@ -17,11 +17,10 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import org.harvestcircle.ffi.generateOperationIdV7
import org.harvestcircle.identities.ui.HarvestCirclePlatformActions
-import org.harvestcircle.identities.ui.HarvestCircleScreen
import org.harvestcircle.identities.ui.HarvestCircleUiActions
import org.harvestcircle.identities.ui.ShutdownFailureScreen
import org.harvestcircle.identities.ui.StartupFailureScreen
-import org.harvestcircle.identities.ui.toUiModel
+import org.harvestcircle.ui.shell.HarvestCircleShell
import java.util.concurrent.atomic.AtomicBoolean
internal typealias HarvestCirclePresenterFactory = (CoroutineScope) -> HarvestCirclePresenter
@@ -76,7 +75,11 @@ internal fun HarvestCircleApplicationWithDependencies(
return
}
checkNotNull(clipboard)
- val state by presenter.state.collectAsState()
+ val shellPresenter = remember(presenter, scope) { HarvestCircleShellPresenter(presenter, presenter.buildInfo, scope) }
+ DisposableEffect(shellPresenter) {
+ onDispose { shellPresenter.close() }
+ }
+ val shellState by shellPresenter.state.collectAsState()
var shutdownProblem by remember { mutableStateOf<String?>(null) }
LaunchedEffect(closeRequested, presenter) {
@@ -97,32 +100,34 @@ internal fun HarvestCircleApplicationWithDependencies(
return
}
- HarvestCircleScreen(
- model = state.toUiModel(),
- actions =
- HarvestCircleUiActions(
- chooseCreateIdentity = { presenter.dispatch(HarvestCircleIntent.ChooseCreateIdentity) },
- chooseImportIdentity = { presenter.dispatch(HarvestCircleIntent.ChooseImportIdentity) },
- cancelIdentityEntry = { presenter.dispatch(HarvestCircleIntent.CancelIdentityEntry) },
- editImportDraft = { presenter.dispatch(HarvestCircleIntent.EditImportDraft(it)) },
- generateIdentity = { presenter.dispatch(HarvestCircleIntent.GenerateIdentity) },
- importSecretKey = { presenter.dispatch(HarvestCircleIntent.ImportIdentity) },
- acknowledgeGeneratedKeyBackup = { presenter.dispatch(HarvestCircleIntent.AcknowledgeGeneratedRecovery) },
- cancelGeneratedKeyBackup = { presenter.dispatch(HarvestCircleIntent.CancelGeneratedRecovery) },
- selectIdentity = { presenter.dispatch(HarvestCircleIntent.SelectIdentity(IdentityId.fromPublicKeyHex(it))) },
- activateIdentity = { presenter.dispatch(HarvestCircleIntent.ActivateIdentity(IdentityId.fromPublicKeyHex(it))) },
- requestIdentityRemoval = {
- presenter.dispatch(HarvestCircleIntent.RequestIdentityRemoval(IdentityId.fromPublicKeyHex(it)))
- },
- cancelIdentityRemoval = { presenter.dispatch(HarvestCircleIntent.CancelIdentityRemoval) },
- confirmIdentityRemoval = { presenter.dispatch(HarvestCircleIntent.ConfirmIdentityRemoval) },
- refreshActiveProfile = { presenter.dispatch(HarvestCircleIntent.RefreshActiveProfile) },
- retryLastCommand = { presenter.dispatch(HarvestCircleIntent.RetryLastCommand) },
- signOut = { presenter.dispatch(HarvestCircleIntent.SignOut) },
- showIdentityChooser = { presenter.dispatch(HarvestCircleIntent.ShowIdentityChooser) },
- hideIdentityChooser = { presenter.dispatch(HarvestCircleIntent.HideIdentityChooser) },
- ),
+ val identityActions =
+ HarvestCircleUiActions(
+ chooseCreateIdentity = { presenter.dispatch(HarvestCircleIntent.ChooseCreateIdentity) },
+ chooseImportIdentity = { presenter.dispatch(HarvestCircleIntent.ChooseImportIdentity) },
+ cancelIdentityEntry = { presenter.dispatch(HarvestCircleIntent.CancelIdentityEntry) },
+ editImportDraft = { presenter.dispatch(HarvestCircleIntent.EditImportDraft(it)) },
+ generateIdentity = { presenter.dispatch(HarvestCircleIntent.GenerateIdentity) },
+ importSecretKey = { presenter.dispatch(HarvestCircleIntent.ImportIdentity) },
+ acknowledgeGeneratedKeyBackup = { presenter.dispatch(HarvestCircleIntent.AcknowledgeGeneratedRecovery) },
+ cancelGeneratedKeyBackup = { presenter.dispatch(HarvestCircleIntent.CancelGeneratedRecovery) },
+ selectIdentity = { presenter.dispatch(HarvestCircleIntent.SelectIdentity(IdentityId.fromPublicKeyHex(it))) },
+ activateIdentity = { presenter.dispatch(HarvestCircleIntent.ActivateIdentity(IdentityId.fromPublicKeyHex(it))) },
+ requestIdentityRemoval = {
+ presenter.dispatch(HarvestCircleIntent.RequestIdentityRemoval(IdentityId.fromPublicKeyHex(it)))
+ },
+ cancelIdentityRemoval = { presenter.dispatch(HarvestCircleIntent.CancelIdentityRemoval) },
+ confirmIdentityRemoval = { presenter.dispatch(HarvestCircleIntent.ConfirmIdentityRemoval) },
+ refreshActiveProfile = { presenter.dispatch(HarvestCircleIntent.RefreshActiveProfile) },
+ retryLastCommand = { presenter.dispatch(HarvestCircleIntent.RetryLastCommand) },
+ signOut = { presenter.dispatch(HarvestCircleIntent.SignOut) },
+ showIdentityChooser = { presenter.dispatch(HarvestCircleIntent.ShowIdentityChooser) },
+ hideIdentityChooser = { presenter.dispatch(HarvestCircleIntent.HideIdentityChooser) },
+ )
+ HarvestCircleShell(
+ state = shellState,
+ identityActions = identityActions,
platformActions = HarvestCirclePlatformActions(copySecret = clipboard::copy),
+ dispatch = shellPresenter::dispatch,
)
}
diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/application/HarvestCircleApplicationTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/application/HarvestCircleApplicationTest.kt
@@ -10,6 +10,8 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
@@ -73,6 +75,38 @@ class HarvestCircleApplicationTest {
@OptIn(ExperimentalTestApi::class)
@Test
+ fun welcomeEntersOneReadOnlyDashboardSession() =
+ runComposeUiTest {
+ setContent { HarvestCircleApplication(presenterFactory = ::testPresenter) }
+ onNodeWithText("Coordinate local food with clear, signed terms.").assertIsDisplayed()
+ onAllNodesWithText("Connect a remote signer").assertCountEquals(0)
+ onNodeWithTag("bootstrap-read-only").performClick()
+ waitUntil { onAllNodesWithTag("foundation-route-body").fetchSemanticsNodes().isNotEmpty() }
+ onNodeWithTag("foundation-route-body").assertIsDisplayed().assertTextEquals("Today")
+ }
+
+ @OptIn(ExperimentalTestApi::class)
+ @Test
+ fun welcomeRoutesToLocalCreateAndImportEntry() =
+ runComposeUiTest {
+ setContent { HarvestCircleApplication(presenterFactory = ::testPresenter) }
+ onNodeWithTag("bootstrap-create").performClick()
+ waitUntil { onAllNodesWithTag("generate-key").fetchSemanticsNodes().isNotEmpty() }
+ onNodeWithTag("generate-key").assertIsDisplayed()
+ }
+
+ @OptIn(ExperimentalTestApi::class)
+ @Test
+ fun welcomeRoutesDirectlyToMaskedImportEntry() =
+ runComposeUiTest {
+ setContent { HarvestCircleApplication(presenterFactory = ::testPresenter) }
+ onNodeWithTag("bootstrap-import").performClick()
+ waitUntil { onAllNodesWithTag("import-nsec-input").fetchSemanticsNodes().isNotEmpty() }
+ onNodeWithTag("import-nsec-input").assertIsDisplayed()
+ }
+
+ @OptIn(ExperimentalTestApi::class)
+ @Test
fun abruptCompositionDisposalCancelsScopeAndFallsBackToNativeClose() =
runComposeUiTest {
var showApplication by mutableStateOf(true)
@@ -201,6 +235,14 @@ class HarvestCircleApplicationTest {
}
}
+private fun testPresenter(scope: kotlinx.coroutines.CoroutineScope): HarvestCirclePresenter =
+ HarvestCirclePresenter(
+ runtime = ApplicationRuntime(),
+ scope = scope,
+ clock = ApplicationClock { UnixSeconds(0) },
+ operationIds = OperationIdSource { OperationId.from(TEST_OPERATION_ID) },
+ )
+
private class ApplicationRuntime(
private val shutdownClosed: Boolean = true,
private val shutdownGate: CompletableDeferred<Unit>? = null,
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt
@@ -35,6 +35,7 @@ class HarvestCirclePresenter(
override val state: StateFlow<HarvestCirclePresenterState> = mutableState.asStateFlow()
val effects: SharedFlow<HarvestCircleEffect> = mutableEffects.asSharedFlow()
+ val buildInfo: BuildInfo = runtime.buildInfo
init {
subscriptionJob =
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCircleShellPresenter.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCircleShellPresenter.kt
@@ -80,7 +80,7 @@ class HarvestCircleShellPresenter(
is HarvestCircleShellIntent.Identity -> identityPresenter.dispatch(intent.intent)
HarvestCircleShellIntent.EnterReadOnly -> updateSession(mutableState.value.session.enterReadOnly())
is HarvestCircleShellIntent.Navigate -> updateNavigation { activateShellDestination(it, intent.destination) }
- is HarvestCircleShellIntent.Navigation -> updateNavigation { state -> NavigationReducer.reduce(state, intent.intent) }
+ is HarvestCircleShellIntent.Navigation -> updateNavigationIntent(intent.intent)
is HarvestCircleShellIntent.Overlay -> mutate { copy(overlays = OverlayReducer.reduce(overlays, intent.intent)) }
is HarvestCircleShellIntent.SetTheme -> mutate { copy(appearance = appearance.copy(theme = intent.theme)) }
is HarvestCircleShellIntent.SetTextSize -> mutate { copy(appearance = appearance.copy(textSize = intent.textSize)) }
@@ -96,10 +96,12 @@ class HarvestCircleShellPresenter(
mutate {
val derived = deriveShellRoot(identity, session)
val retained =
- if (derived is ShellRoot.Dashboard && root is ShellRoot.Dashboard) {
- derived.copy(navigation = root.navigation)
- } else {
- derived
+ when {
+ derived is ShellRoot.Dashboard && root is ShellRoot.Dashboard ->
+ derived.copy(navigation = root.navigation)
+ derived is ShellRoot.BootstrapCanvas && root is ShellRoot.BootstrapCanvas ->
+ derived.copy(step = root.step)
+ else -> derived
}
copy(identity = identity, root = retained)
}
@@ -116,6 +118,24 @@ class HarvestCircleShellPresenter(
}
}
+ private fun updateNavigationIntent(intent: NavigationIntent) {
+ mutate {
+ when (val currentRoot = root) {
+ is ShellRoot.Dashboard ->
+ copy(
+ root = currentRoot.copy(navigation = NavigationReducer.reduce(currentRoot.navigation, intent)),
+ )
+ is ShellRoot.BootstrapCanvas ->
+ if (intent is NavigationIntent.SelectBootstrapStep) {
+ copy(root = currentRoot.copy(step = intent.step))
+ } else {
+ this
+ }
+ is ShellRoot.LifecycleCanvas -> this
+ }
+ }
+ }
+
private fun mutate(block: HarvestCircleShellState.() -> HarvestCircleShellState) {
mutableState.value = mutableState.value.block()
}
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt
@@ -0,0 +1,177 @@
+package org.harvestcircle.ui.shell
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.unit.dp
+import org.harvestcircle.application.FoundationOverlay
+import org.harvestcircle.application.HarvestCircleIntent
+import org.harvestcircle.application.HarvestCircleShellIntent
+import org.harvestcircle.application.HarvestCircleShellState
+import org.harvestcircle.application.OverlayIntent
+import org.harvestcircle.application.ShellDestination
+import org.harvestcircle.application.ShellRoot
+import org.harvestcircle.application.SignerAvailability
+import org.harvestcircle.identities.ui.HarvestCirclePlatformActions
+import org.harvestcircle.identities.ui.HarvestCircleScreen
+import org.harvestcircle.identities.ui.HarvestCircleUiActions
+import org.harvestcircle.identities.ui.toUiModel
+import org.harvestcircle.navigation.AppRoute
+import org.harvestcircle.navigation.BootstrapStep
+import org.harvestcircle.navigation.NavigationIntent
+
+@Composable
+fun HarvestCircleShell(
+ state: HarvestCircleShellState,
+ identityActions: HarvestCircleUiActions,
+ platformActions: HarvestCirclePlatformActions,
+ dispatch: (HarvestCircleShellIntent) -> Unit,
+) {
+ when (val root = state.root) {
+ is ShellRoot.LifecycleCanvas ->
+ HarvestCircleScreen(state.identity.toUiModel(), identityActions, platformActions)
+ is ShellRoot.BootstrapCanvas ->
+ when (root.step) {
+ BootstrapStep.Welcome -> BootstrapWelcome(dispatch)
+ else -> HarvestCircleScreen(state.identity.toUiModel(), identityActions, platformActions)
+ }
+ is ShellRoot.Dashboard -> DashboardRoot(state, root, dispatch)
+ }
+ FoundationOverlayHost(state.overlays) { dispatch(HarvestCircleShellIntent.Overlay(it)) }
+}
+
+@Composable
+private fun BootstrapWelcome(dispatch: (HarvestCircleShellIntent) -> Unit) {
+ CanvasScaffold(
+ textSize = org.harvestcircle.design.TextSizePreference.Default,
+ header = { BasicText("HarvestCircle") },
+ body = {
+ Column(
+ Modifier.testTag("bootstrap-welcome"),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ BasicText("Coordinate local food with clear, signed terms.")
+ BasicText("HarvestCircle helps farms and nearby buyers form one shared order.")
+ BasicText("You do not need a HarvestCircle account.")
+ BasicText("A farm opens a round")
+ BasicText("The farm publishes the available boxes, pickup terms, and price levels.")
+ BasicText("Buyers make private commitments")
+ BasicText("Each buyer signs a maximum amount without publishing their identity.")
+ BasicText("The authority clears the round")
+ BasicText("The selected authority applies the farm’s signed terms and issues allocations.")
+ BasicText("Open source · Nostr-based · No managed service required")
+ }
+ },
+ actionBar = {
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ ShellAction("Explore read-only", "Explore read-only", "bootstrap-read-only") {
+ dispatch(HarvestCircleShellIntent.EnterReadOnly)
+ }
+ ShellAction("Create a local Nostr identity", "Create a local Nostr identity", "bootstrap-create") {
+ dispatch(HarvestCircleShellIntent.Identity(HarvestCircleIntent.ChooseCreateIdentity))
+ dispatch(
+ HarvestCircleShellIntent.Navigation(
+ NavigationIntent.SelectBootstrapStep(BootstrapStep.CreateIdentity),
+ ),
+ )
+ }
+ ShellAction("Import an existing identity", "Import an existing identity", "bootstrap-import") {
+ dispatch(HarvestCircleShellIntent.Identity(HarvestCircleIntent.ChooseImportIdentity))
+ dispatch(
+ HarvestCircleShellIntent.Navigation(
+ NavigationIntent.SelectBootstrapStep(BootstrapStep.ImportIdentity),
+ ),
+ )
+ }
+ }
+ },
+ )
+}
+
+@Composable
+private fun DashboardRoot(
+ state: HarvestCircleShellState,
+ root: ShellRoot.Dashboard,
+ dispatch: (HarvestCircleShellIntent) -> Unit,
+) {
+ val route = root.navigation.current
+ val destination =
+ when (route) {
+ AppRoute.PersonalToday -> ShellDestination.Today
+ AppRoute.Network -> ShellDestination.Network
+ is AppRoute.Settings -> ShellDestination.Settings
+ is AppRoute.Bootstrap -> ShellDestination.Today
+ }
+ DashboardScaffold(
+ windowWidthDp = ShellDimensions.PREFERRED_WINDOW_WIDTH_DP,
+ inspectorVisible = false,
+ topBar = {
+ GlobalTopBar(
+ model =
+ GlobalTopBarModel(
+ canGoBack = root.navigation.backStack.isNotEmpty(),
+ canGoForward = root.navigation.forwardStack.isNotEmpty(),
+ syncStatus = syncStatus(state),
+ signerStatus = signerStatus(state),
+ ),
+ onIntent = { intent -> dispatchTopBar(intent, dispatch) },
+ )
+ },
+ sidebar = { WorkspaceSidebar(destination) { dispatch(HarvestCircleShellIntent.Navigate(it)) } },
+ mainHeader = { MainPanelHeader(MainPanelHeaderModel(title = route.title())) },
+ mainBody = { BasicText(route.title(), Modifier.testTag("foundation-route-body")) },
+ )
+}
+
+private fun dispatchTopBar(
+ intent: GlobalTopBarIntent,
+ dispatch: (HarvestCircleShellIntent) -> Unit,
+) {
+ val shellIntent =
+ when (intent) {
+ GlobalTopBarIntent.Back -> HarvestCircleShellIntent.Navigation(NavigationIntent.Back)
+ GlobalTopBarIntent.Forward -> HarvestCircleShellIntent.Navigation(NavigationIntent.Forward)
+ GlobalTopBarIntent.OpenNostrReference ->
+ HarvestCircleShellIntent.Overlay(OverlayIntent.Open(FoundationOverlay.OpenNostrReference()))
+ GlobalTopBarIntent.ShowSyncStatus ->
+ HarvestCircleShellIntent.Overlay(OverlayIntent.Open(FoundationOverlay.SyncStatus(SyncStatusLabel.NotYetObserved)))
+ GlobalTopBarIntent.ShowSignerStatus ->
+ HarvestCircleShellIntent.Overlay(OverlayIntent.Open(FoundationOverlay.SignerStatus(SignerStatusLabel.SignedOut)))
+ GlobalTopBarIntent.OpenApplicationMenu ->
+ HarvestCircleShellIntent.Navigate(ShellDestination.Settings)
+ }
+ dispatch(shellIntent)
+}
+
+private fun syncStatus(state: HarvestCircleShellState): SyncStatusLabel =
+ when (
+ state.identity.snapshot.activeIdentity
+ ?.relays
+ ?.state
+ ) {
+ org.harvestcircle.application.RelayConnectionState.Connected -> SyncStatusLabel.Available
+ org.harvestcircle.application.RelayConnectionState.Degraded -> SyncStatusLabel.Degraded
+ org.harvestcircle.application.RelayConnectionState.Error -> SyncStatusLabel.Unavailable
+ else -> SyncStatusLabel.NotYetObserved
+ }
+
+private fun signerStatus(state: HarvestCircleShellState): SignerStatusLabel =
+ when {
+ state.session.readOnly -> SignerStatusLabel.ReadOnly
+ state.identity.snapshot.activeIdentity == null -> SignerStatusLabel.SignedOut
+ state.identity.snapshot.activeIdentity.identity.signer.availability == SignerAvailability.Available ->
+ SignerStatusLabel.Available
+ else -> SignerStatusLabel.CredentialMissing
+ }
+
+private fun AppRoute.title(): String =
+ when (this) {
+ AppRoute.PersonalToday -> "Today"
+ AppRoute.Network -> "Network"
+ is AppRoute.Settings -> "Settings"
+ is AppRoute.Bootstrap -> "HarvestCircle"
+ }