Skip to content

feat(desktop): wallet screen on Compose Desktop (A2) - #1844

Closed
bmc08gt wants to merge 17 commits into
feat/desktop-spike-a1from
feat/desktop-spike-a2
Closed

bmc08gt wants to merge 17 commits into
feat/desktop-spike-a1from
feat/desktop-spike-a2

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #1843. Moves the UI modules on the balance path to android + jvm KMP and adds :apps:desktop, a Compose Desktop window that renders the wallet screen with a live balance from the same view models Android uses.

What moves

  • ui:theme, ui:core, ui:components, ui:navigation, ui:resources, shared:analytics, shared:userflags, core, core-ui, common-ui, shared:transaction-history, shared:funding, shared:tokens, features:balance.
  • Each module is converted, src/main moves to src/androidMain, and only the files the balance screen needs are lifted into commonMain. Everything else stays Android-only, so the Android app is unchanged apart from imports.
  • View models: commonMain holds open class WalletViewModel @Inject constructor(...); androidMain adds a @HiltViewModel subclass, and Android call sites use it. Desktop gets the common class from the Metro graph.
  • shared:transaction-history no longer depends on shared:chat. It reads tips through a TipHistorySource interface.

Resources and icons

AndroidResAsComposeResources (build-logic) syncs each module's res/values and res/drawable into Compose resources at build time. Common code reads them through per-module id objects (CoreStrings, CoreDrawables, TxStrings, ...) whose Android actuals are R ids. Vector XML is not hand-ported or duplicated; 8 drawables are referenced from common code so far.

Desktop DI

DesktopGraph is a Metro graph over the existing Dagger modules (javax/Dagger interop). Android keeps Hilt. Hilt runs through kspAndroid without the Hilt Gradle plugin, which refuses the KMP android target; only the app and notifications modules apply the plugin.

Wiring that Android does in Android-only places had to be repeated in DesktopModule:

  • TokenCoordinator joins the SessionListener set (Android: TokenModule).
  • EventRepository is constructed before login (Android: FlipcashApp).
  • Curves.initialize() loads the bonding curve tables (Android: the Application).

services:opencode now applies Metro itself instead of running dagger-compiler on jvm. Through Dagger's generated factories, Metro 1.4.5 dropped provider-level @Singleton, so every injection site got its own ChannelEventBus.

Dev sign-in

Until link-from-phone lands (A3), desktop signs in with flipcash.test.mnemonic from local.properties. Gradle passes it to the desktop JavaExec tasks as FLIPCASH_DEV_MNEMONIC, only when set. It is not on the command line and never logged. Without it the window opens signed out.

Run: ./gradlew :apps:desktop:run, or ./gradlew :apps:desktop:snapshot -Pout=wallet.png for an off-screen render.

Known gaps

  • Token logos on the card stack do not render on desktop.
  • The network observer is a stub that always reports connected.
  • Purchase methods are stubbed; the balance screen's buttons go nowhere.

…ose resources

:apps:desktop is a kotlin.jvm + compose.desktop app with an empty Metro
graph. ui:resources now also generates a Compose Multiplatform Res class
from its Android res/ through a Sync task, so common code can read the
same strings and drawables without a second copy in the repo.
Fonts load through an expect seam: Android keeps R.font, desktop reads
the same files from the classpath. Dynamic color and the View helpers
stay in androidMain.
Only the files the balance screen reaches move to commonMain; the rest
stay in androidMain unchanged. Drawable lookups by Android resource id go
through painterForResId, with a desktop ResourceHelper over Compose
resources. Blur support and hex colour parsing get small platform seams.
CodeNavigator, its options, flow scope and the nav-result types move to
commonMain; navigation3 1.1.4 publishes a desktop variant, so the
back stack type is the same artifact on both platforms. Scenes, flow
hosts and AppNavHost stay in androidMain.

RepeatOnLifecycle reads LocalLifecycleOwner from lifecycle-runtime-compose,
the class the old androidx.compose.ui.platform one already aliases on
Android.
The FlipcashAnalytics interface, the token symbol resolver and the
CompositionLocal move to common code. Conversions (toUri and app:core's
DeeplinkType) and the Mixpanel delegate stay Android. RecordingAnalytics
moves to a :test-fixtures module, since the KMP Android target has no
testFixtures; its 10 consumers point at that module.

Also moves ui:navigation's BaseViewModel to common code; it uses only
lifecycle and coroutines.
Flag labels sit behind a FlagStrings expect object (Android keeps the
R.string ids; desktop has no flag editor). UserFlagsFiles replaces the
Context parameter so the DataStore file comes from the platform.
AppRoute, its step and result types, DeviceContact and TipCardOwner move
to app:core's commonMain with the shared Parcelable aliases. The three
files that use Uri or R (CurrencyCreatorDraft, TokenDisplayName,
DeeplinkType) stay in androidMain. FlowRoute moves to ui:navigation's
commonMain.

ui:resources adds stringRes/pluralStringRes for composables that hold an
Int resource ID, and lets other modules register their own Compose
resources with ComposeResourceIds to get an Int ID on desktop.
… composables

Lifts the nine composables the balance screen and activity feed render
(TileButton, TokenCard, TokenCardStack, TokenImageWithName, ViewerAmount,
ActivityAmount, CurrencyAppreciationLabel, FlagWithFiat, NewUserTutorial)
to commonMain. Everything else stays in androidMain unchanged.

Seams: screenHeightPx (Configuration on Android, window container on
desktop) and ImageRequestPlaceholders for Coil placeholders. Flags load
through painterForResId. app:core exposes its res/ as Compose resources
and gains CoreStrings/CoreDrawables id objects for the lifted files.
opencode-compose moves to KMP with LocalExchange in commonMain.
…vatar

BlurHash decodes to pixels in common code; each platform wraps them (Android
still builds the same ARGB_8888 Bitmap, and keeps BlurHash.decode as an
extension). ContactAvatar uses Coil's LocalPlatformContext and passes the URL
string to Coil, which maps it as it did the Uri. app:core's media resolver and
TimeProvider move to common; the Coil-backed ImageCachePresence stays Android.
Lift the activity feed coordinator and updater, both mappers and the
recent-activity composables to commonMain. String ids go through a
TxStrings expect object (R.string on Android, the Compose resource
registry on desktop); the 24-hour clock check is an expect seam.
NetworkUpdater moves to app:core's commonMain.
PurchaseMethod, PurchaseMethodController and PurchaseMethodState move to
commonMain so the balance view model can take the controller on desktop.
The controller implementation and its UI stay in androidMain. The module
moves to flipcash.kmp.app.library, since its app-module dependencies
have no Apple targets.
…let coordinators

TokenCoordinator, WalletRevealCoordinator, TokenSelectionResolver and
SelectTokenViewModel move to commonMain; the rest of the module stays in
androidMain.

Two app:core seams replace the Android types TokenCoordinator used:
- PreferencesFiles hands out the DataStore file; Android binds it to
  Context.preferencesDataStoreFile through Hilt.
- AppProcessLifecycle is ProcessLifecycleOwner on Android and a
  lifecycle held in RESUMED on desktop, since lifecycle-process has no
  desktop artifact.

SelectTokenViewModel becomes an open class without Hilt annotations.
HiltSelectTokenViewModel in androidMain carries @hiltviewmodel, and the
ten hiltViewModel<SelectTokenViewModel>() call sites across eight
features now request it.
WalletViewModel becomes an open class in commonMain with an @Inject
constructor; the @hiltviewmodel subclass HiltWalletViewModel stays in
androidMain, and the Android entry point asks Hilt for it. The wallet
content and header composables move to commonMain and read their
strings and drawables through CoreStrings/CoreDrawables.

The view model only called ChatCoordinator.hasEverTipped(), so it now
takes a one-method TipHistorySource from app:core. ChatCoordinator
extends it and ChatModule binds it, so Android injects the same object
and balance no longer depends on shared:chat (10k lines).

ReportDrawnWhen becomes an expect seam (no-op on jvm). card-expand
moves to android+jvm with everything in commonMain.
Desktop builds WalletViewModel from a Metro graph that reuses the
Dagger modules Android's Hilt graph installs. OpenCodeModule and
FlipcashModule split into shared providers (commonMain), the Android
channel/protocol config (androidMain) and an okhttp channel module in
apps/desktop. Internal @provides functions carry @JvmName so Dagger's
factory names stay unmangled for Metro's runtime interop.

DesktopModule binds the ten seams Android provides from Hilt modules;
funding, analytics and connectivity are stubs. shared:theme moves to
common so the window paints the Flipcash colour scheme.
DevSession reads FLIPCASH_DEV_MNEMONIC, which Gradle sets for the desktop
JavaExec tasks only when local.properties has flipcash.test.mnemonic. It
goes in the environment rather than a -D flag so the phrase stays out of
the process command line, and it is never logged.

Four gaps kept the wallet on its loading state after sign-in:

- Nothing constructed EventRepository, which turns Events.OnLoggedIn into
  SessionListener.onUserLoggedIn. Android builds it eagerly in
  FlipcashApp; the graph now exposes it and devSignIn touches it first.
- TokenCoordinator joins the listener set in TokenModule, which is
  Android-only. DesktopModule now adds it.
- Through Dagger's generated factories, Metro 1.4.5 drops provider-level
  @singleton, so each injection site got its own ChannelEventBus and
  events never reached the repository. services:opencode now applies
  Metro with Dagger interop and generates its own factories instead of
  running dagger-compiler on jvm.
- The bonding curve tables were never loaded, so every balance threw
  "DiscreteBondingCurve not initialized" inside a retry that swallowed
  it. devSignIn calls Curves.initialize() first, as Android's
  Application does.

OpenCodeExchange's state fields move above init: init hands streamRates
to another thread, which could read them before they were assigned.
@bmc08gt bmc08gt self-assigned this Oct 23, 2026
@github-actions github-actions Bot added type: feature New functionality area: payments Payments, transfers, intents, billing area: ui Compose UI, theme, components, resources area: network gRPC, connectivity, API, exchange rates area: onramp Deposit, purchase, Coinbase, fiat on-ramp area: build-system Gradle, convention plugins, build-logic area: tokens Token accounts, balances, token info area: session area: onboarding and removed type: feature New functionality labels Oct 23, 2026
Token logos are https URLs. Coil 3 ships no network fetcher, and the
desktop app had no coil-network artifact on its classpath, so every logo
failed with "Unable to create a fetcher" and the card stack showed blank
avatars. coil-network-okhttp registers its fetcher through ServiceLoader,
so adding the dependency is the fix.

installImageLoader mirrors FlipcashApp.newImageLoader (crossfade, memory
cache, 50 MB disk cache), minus the chat photo keyer and fetcher.
…tract

Each wiring gap in A2 (the token coordinator's listener binding, the
eager event repository, the curve tables, the network image loader)
left the desktop wallet on a spinner or blank logos with no error.

:apps:flipcash:startup-contract lists the SessionListeners desktop must
bind, the ones only Android binds, and every Android startup entry point,
each marked as mirrored on desktop (with a Step) or not, with a reason.

- AndroidWiringTest builds the real Hilt graph under Robolectric and
  fails when a SessionListener or startup entry point (manifest
  initializer, FlipcashApp injection, SingletonImageLoader.Factory) is
  missing from the contract. ApiModule is replaced because
  MixpanelAPI.getInstance returns null under Robolectric.
- DesktopWiringTest builds the real Metro graph, runs
  DesktopGraph.startup(), and checks the effect of each mirrored step,
  the listener set, and that scoped bindings resolve to one instance.

The desktop startup steps move out of devSignIn into startup() so the
test can run them without a sign-in phrase. Tests set
opencode.processLifecycle.autoStart=false so building the exchange does
not open its rate stream to the backend.
@bmc08gt

bmc08gt commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator Author

Landed in #1894 as one squash against code/cash.

@bmc08gt bmc08gt closed this Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: build-system Gradle, convention plugins, build-logic area: network gRPC, connectivity, API, exchange rates area: onboarding area: onramp Deposit, purchase, Coinbase, fiat on-ramp area: payments Payments, transfers, intents, billing area: session area: tokens Token accounts, balances, token info area: ui Compose UI, theme, components, resources type: feature New functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant