Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
d25809b
build(desktop): add the Compose Desktop shell and Android res as Comp…
bmc08gt Oct 23, 2026
ae1ec91
build(theme): move ui:theme to android+jvm KMP
bmc08gt Oct 23, 2026
82d2635
build(ui): move ui:core and ui:components to android+jvm KMP
bmc08gt Oct 23, 2026
80adf96
build(navigation): move ui:navigation to android+jvm KMP
bmc08gt Oct 23, 2026
6672b93
build(analytics): move shared:analytics to android+jvm KMP
bmc08gt Oct 23, 2026
924470c
build(userflags): move shared:userflags to android+jvm KMP
bmc08gt Oct 23, 2026
90afa5e
build(core): lift AppRoute and flow step types to common
bmc08gt Oct 23, 2026
57f1d91
build(core-ui): move core-ui to android+jvm KMP and lift balance-path…
bmc08gt Oct 23, 2026
61c39c8
build(common-ui): move common-ui to android+jvm KMP and lift ContactA…
bmc08gt Oct 23, 2026
3d02a3e
build(transaction-history): move transaction-history to android+jvm KMP
bmc08gt Oct 23, 2026
0db5af1
build(funding): lift PurchaseMethod types to common
bmc08gt Oct 23, 2026
19e03d4
build(tokens): move shared:tokens to android+jvm KMP and lift the wal…
bmc08gt Oct 23, 2026
3cbffbd
build(balance): move features:balance to android+jvm KMP
bmc08gt Oct 23, 2026
63a2175
feat(desktop): Metro graph and wallet window
bmc08gt Oct 23, 2026
c9ef6dc
feat(desktop): sign in with a dev phrase and show the live balance
bmc08gt Oct 23, 2026
d585683
fix(desktop): add Coil's OkHttp network fetcher so token logos load
bmc08gt Oct 24, 2026
7a7b785
test(desktop): check desktop wiring against Android's in a shared con…
bmc08gt Oct 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
88 changes: 88 additions & 0 deletions apps/desktop/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat

plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.jetbrains.compose)
alias(libs.plugins.metro)
}

kotlin {
jvmToolchain(21)
}

metro {
interop {
includeJavax()
includeDagger()
enableDaggerRuntimeInterop.set(true)
}
}

dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.jetbrains.compose.material3)
implementation(project(":services:opencode"))
implementation(project(":apps:flipcash:features:balance"))
implementation(project(":apps:flipcash:core"))
implementation(project(":apps:flipcash:shared:transaction-history"))
implementation(project(":apps:flipcash:shared:tokens"))
implementation(project(":apps:flipcash:shared:funding"))
implementation(project(":apps:flipcash:shared:userflags"))
implementation(project(":apps:flipcash:shared:analytics"))
implementation(project(":apps:flipcash:shared:persistence:sources"))
implementation(project(":apps:flipcash:shared:persistence:db"))
implementation(project(":services:flipcash"))
implementation(project(":libs:coroutines"))
implementation(project(":libs:logging"))
implementation(project(":ui:resources"))
implementation(project(":ui:theme"))
implementation(project(":apps:flipcash:shared:theme"))
implementation(project(":services:opencode-compose"))
implementation(project(":libs:locale:public"))
implementation(project(":libs:network:connectivity:public"))
implementation(project(":libs:encryption:chat-cipher"))
implementation(libs.kotlinx.coroutines.swing)
implementation(libs.dagger)
implementation(libs.grpc.okhttp)
implementation(libs.coil3)
implementation(libs.coil3.network)
implementation(libs.event.bus)

testImplementation(project(":apps:flipcash:startup-contract"))
testImplementation(libs.kotlin.test.junit)
testImplementation(libs.kotlinx.coroutines.test)
}

tasks.withType<Test>().configureEach {
// No backend in tests: keep the exchange's rate stream from starting when the graph builds it.
systemProperty("opencode.processLifecycle.autoStart", "false")
}

// Dev-only sign-in phrase (see DevSession), forwarded only when local.properties has one. It goes in
// the environment, not a -D flag, so it stays out of the process's command line.
val devMnemonic = tryReadProperty(rootProject.rootDir, "flipcash.test.mnemonic", "")
.takeIf { it.isNotBlank() }

compose.desktop {
application {
mainClass = "com.flipcash.desktop.MainKt"
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "Flipcash"
packageVersion = "1.0.0"
}
}
}

tasks.register<JavaExec>("snapshot") {
group = "application"
description = "Renders the wallet screen off-screen to a PNG (-Pout=<file>)."
classpath = sourceSets["main"].runtimeClasspath
mainClass.set("com.flipcash.desktop.SnapshotMainKt")
args(providers.gradleProperty("out").getOrElse(layout.buildDirectory.file("wallet.png").get().asFile.path))
}

tasks.withType<JavaExec>().configureEach {
devMnemonic?.let { environment("FLIPCASH_DEV_MNEMONIC", it) }
}
51 changes: 51 additions & 0 deletions apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopGraph.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.flipcash.desktop

import com.flipcash.app.analytics.FlipcashAnalytics
import com.flipcash.app.balance.internal.WalletViewModel
import com.flipcash.app.tokens.ui.SelectTokenViewModel
import com.getcode.opencode.exchange.Exchange
import com.getcode.util.resources.ResourceHelper
import com.getcode.utils.network.NetworkConnectivityListener
import com.flipcash.desktop.inject.FlipcashDesktopChannelModule
import com.flipcash.services.inject.FlipcashModule
import com.flipcash.desktop.inject.OpenCodeDesktopChannelModule
import com.getcode.opencode.inject.OpenCodeModule
import com.getcode.opencode.inject.SessionListenerModule
import com.getcode.opencode.inject.TransactorModule
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.DependencyGraph
import javax.inject.Singleton

@Singleton
@DependencyGraph(
scope = AppScope::class,
bindingContainers = [
OpenCodeModule::class,
OpenCodeDesktopChannelModule::class,
SessionListenerModule::class,
TransactorModule::class,
FlipcashModule::class,
com.flipcash.services.inject.ChatCryptoModule::class,
FlipcashDesktopChannelModule::class,
DesktopModule::class,
],
)
interface DesktopGraph {
val walletViewModel: WalletViewModel
val selectTokenViewModel: SelectTokenViewModel
val exchange: Exchange
val resources: ResourceHelper
val networkObserver: NetworkConnectivityListener
val analytics: FlipcashAnalytics
val userManager: com.flipcash.services.user.UserManager
val accountController: com.flipcash.services.controllers.AccountController

// Android constructs this eagerly in FlipcashApp.onCreate; it is what turns Events.OnLoggedIn into
// SessionListener.onUserLoggedIn calls.
val eventRepository: com.getcode.opencode.repositories.EventRepository

// For DesktopWiringTest: the listener set, and singletons to check are shared across injection sites.
val sessionListeners: Set<com.getcode.opencode.providers.SessionListener>
val tokenCoordinator: com.flipcash.app.tokens.TokenCoordinator
val eventBus: com.hoc081098.channeleventbus.ChannelEventBus
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.flipcash.desktop

import coil3.ImageLoader
import coil3.PlatformContext
import coil3.SingletonImageLoader
import coil3.disk.DiskCache
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.CachePolicy
import coil3.request.crossfade
import okio.Path.Companion.toOkioPath

/**
* Desktop counterpart of `FlipcashApp.newImageLoader`. Coil 3 has no network fetcher unless one is
* added, so without this every token logo (an https URL) fails with "Unable to create a fetcher".
* The chat photo keyer/fetcher are left out: chat is not on desktop.
*/
internal fun installImageLoader() {
SingletonImageLoader.setSafe { context -> newImageLoader(context) }
}

private fun newImageLoader(context: PlatformContext): ImageLoader =
ImageLoader.Builder(context)
.crossfade(true)
.memoryCachePolicy(CachePolicy.ENABLED)
.diskCache {
DiskCache.Builder()
.directory(DesktopModule.appDir.resolve("image_cache").toOkioPath())
.maxSizeBytes(50L * 1024 * 1024)
.build()
}
.components { add(OkHttpNetworkFetcherFactory()) }
.build()
120 changes: 120 additions & 0 deletions apps/desktop/src/main/kotlin/com/flipcash/desktop/DesktopModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package com.flipcash.desktop

import com.flipcash.app.analytics.FlipcashAnalytics
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.chat.TipHistorySource
import com.flipcash.app.core.storage.DirectoryPreferencesFiles
import com.flipcash.app.core.storage.PreferencesFiles
import com.flipcash.app.funding.PurchaseMethod
import com.flipcash.app.funding.PurchaseMethodController
import com.flipcash.app.funding.PurchaseMethodMetadata
import com.flipcash.app.funding.PurchaseMethodSelection
import com.flipcash.app.funding.PurchaseMethodState
import com.flipcash.app.persistence.sources.ChatMessageDataSource
import com.flipcash.app.tokens.TokenCoordinator
import com.flipcash.app.userflags.UserFlagsFiles
import com.flipcash.libs.coroutines.DispatcherProvider
import com.getcode.chatcipher.ChatCipher
import com.getcode.chatcipher.DefaultChatCipher
import com.getcode.opencode.providers.SessionListener
import com.getcode.opencode.providers.TokenMetadataProvider
import com.getcode.util.locale.LocaleHelper
import com.getcode.util.resources.ComposeResourceHelper
import com.getcode.util.resources.ResourceHelper
import com.getcode.utils.network.NetworkConnectivityListener
import com.getcode.utils.network.NetworkObserverStub
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoSet
import java.io.File
import java.util.Currency
import java.util.Locale
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.map

/**
* Desktop implementations of the seams Android binds in its Hilt modules. Funding, analytics and
* connectivity are stubs for this slice; the rest are real.
*/
@Module
object DesktopModule {
/** Per-user data directory: the database, DataStore files and no-backup files. */
internal val appDir: File by lazy {
File(System.getProperty("user.home"), ".flipcash").apply { mkdirs() }
}

@Provides
@Singleton
fun dispatchers(): DispatcherProvider = object : DispatcherProvider {
override val Default: CoroutineDispatcher = Dispatchers.Default
override val Main: CoroutineDispatcher = Dispatchers.Main
override val IO: CoroutineDispatcher = Dispatchers.IO
}

// ChatCryptoModule provides this from its companion object, which Metro's Dagger interop
// does not read from a precompiled module.
@Provides
fun chatCipher(): ChatCipher = DefaultChatCipher

@Provides
fun analytics(): FlipcashAnalytics = FlipcashAnalytics.None

@Provides
@Singleton
fun locale(): LocaleHelper = object : LocaleHelper {
override fun getLanguageTag(): String = Locale.getDefault().toLanguageTag()
override fun getDefaultCurrencyName(): String =
runCatching { Currency.getInstance(Locale.getDefault()).currencyCode }.getOrDefault("USD")
override suspend fun getDefaultCountry(): String? =
Locale.getDefault().country.takeIf { it.length == 2 }
}

@Provides
@Singleton
fun network(): NetworkConnectivityListener = NetworkObserverStub()

@Provides
@Singleton
fun preferencesFiles(): PreferencesFiles = DirectoryPreferencesFiles(appDir)

@Provides
@Singleton
fun userFlagsFiles(): UserFlagsFiles = object : UserFlagsFiles {
override val noBackupDir: File = File(appDir, "no_backup").apply { mkdirs() }
override fun dataStoreFile(name: String): File = File(appDir, "datastore/$name.preferences_pb")
}

@Provides
@Singleton
fun resources(): ResourceHelper = ComposeResourceHelper()

@Provides
fun tipHistory(messages: ChatMessageDataSource): TipHistorySource = object : TipHistorySource {
override fun hasEverTipped(): Flow<Boolean?> = messages.hasEverTipped().map { it }
}

@Provides
fun tokenMetadata(coordinator: TokenCoordinator): TokenMetadataProvider = coordinator

// Android adds TokenCoordinator to the listener set in TokenModule, which is Android-only. Without this
// the coordinator never sees onUserLoggedIn and the wallet stays on its loading state.
@Provides
@IntoSet
fun tokenSessionListener(coordinator: TokenCoordinator): SessionListener = coordinator

@Provides
@Singleton
fun purchaseMethods(): PurchaseMethodController = object : PurchaseMethodController {
override val state: StateFlow<PurchaseMethodState> = MutableStateFlow(PurchaseMethodState())
override val selections: Flow<PurchaseMethodSelection> = emptyFlow()
override fun present(metadata: PurchaseMethodMetadata) = Unit
override fun select(method: PurchaseMethod, metadata: PurchaseMethodMetadata) = Unit
override suspend fun presentDepositOptions(popToRoot: Boolean): AppRoute? = null
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.flipcash.desktop

import com.flipcash.libs.currency.math.Curves
import com.flipcash.libs.currency.math.initialize

/**
* The startup work Android does in its androidx-startup initializers and `FlipcashApp.onCreate`
* that the balance path relies on. `StartupContract` lists each step; `DesktopWiringTest` checks
* that each took effect.
*/
internal suspend fun DesktopGraph.startup() {
// NetworkImageLoader: token logos are https URLs, and Coil 3 has no network fetcher by default.
installImageLoader()
// CurveTables: balance valuations throw without them.
Curves.initialize()
// EventRepository: must exist before login, or OnLoggedIn reaches no session listener.
eventRepository
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.flipcash.desktop

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.flipcash.app.analytics.LocalAnalytics
import com.flipcash.app.balance.internal.WalletScreen
import com.flipcash.app.core.tokens.TokenPurpose
import com.flipcash.app.theme.FlipcashTheme
import com.flipcash.app.tokens.ui.SelectTokenViewModel
import com.getcode.opencode.compose.LocalExchange
import com.getcode.theme.CodeTheme
import com.getcode.util.resources.LocalResources
import com.getcode.utils.network.LocalNetworkObserver

/**
* The wallet screen with the locals and window background that Android's app root provides
* (`App.kt` paints [CodeTheme.colors.background] behind every screen). The graph doesn't scope view
* models, so each read of [DesktopGraph.walletViewModel] builds a new one; they're held here.
*/
@Composable
fun DesktopWalletContent(graph: DesktopGraph) {
val walletViewModel = remember { graph.walletViewModel }
val tokenViewModel = remember { graph.selectTokenViewModel }
LaunchedEffect(tokenViewModel) {
tokenViewModel.dispatchEvent(SelectTokenViewModel.Event.OnPurposeChanged(TokenPurpose.Balance))
}
FlipcashTheme {
CompositionLocalProvider(
LocalExchange provides graph.exchange,
LocalResources provides graph.resources,
LocalNetworkObserver provides graph.networkObserver,
LocalAnalytics provides graph.analytics,
) {
Box(Modifier.fillMaxSize().background(CodeTheme.colors.background)) {
WalletScreen(walletViewModel, tokenViewModel)
}
}
}
}
Loading
Loading