app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

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:
Mapp/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleApplication.kt | 61+++++++++++++++++++++++++++++++++----------------------------
Mapp/desktop/src/test/kotlin/org/harvestcircle/application/HarvestCircleApplicationTest.kt | 42++++++++++++++++++++++++++++++++++++++++++
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt | 1+
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCircleShellPresenter.kt | 30+++++++++++++++++++++++++-----
Aapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt | 177+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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" + }