diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 37e251f..ced0a08 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -6,6 +6,7 @@ on: paths: - "website/**" - "shared/**" + - "sharedUi/**" - "webApp/**" - "gradle/**" - "gradlew" diff --git a/AGENTS.md b/AGENTS.md index 1384ea2..755325c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,10 +24,28 @@ Kotlin) implementation — `main` is now this Kotlin Multiplatform project. ## Module layout -- `shared/` — the actual app: UI, view models, data sources, domain logic. - Almost everything happens here. +- `shared/` — view models, data sources, domain logic. No Compose UI + toolkit dependency (deliberately - see `sharedUi/` below). Almost all + business logic happens here. +- `sharedUi/` — the Skia-based Compose Multiplatform UI (`App()`, + `ui/PriceScreen.kt`) that `androidApp`, `desktopApp`, and `webApp` + render. Split out from `shared` so a js/wasmJs consumer that doesn't + want Compose UI's several-MB Skia web runtime (see `webHtmlApp/`) can + depend on `shared` alone without pulling it in transitively - the + Compose Multiplatform Gradle plugin bundles that runtime into *any* + js/wasmJs target whose resolved dependencies contain + `org.jetbrains.compose.ui:ui` anywhere. - `androidApp/`, `desktopApp/`, `webApp/` — thin launcher shells around - `shared`'s Compose UI. Rarely need changes. + `sharedUi`'s Compose UI. Rarely need changes. +- `webHtmlApp/` — an alternative web build rendering to real DOM via + Compose HTML instead of Compose Multiplatform UI's canvas/Skia. Depends + on `shared` directly, not `sharedUi`: Compose HTML and Compose + Multiplatform UI are different, incompatible composable sets, so its + screens (`HtmlApp.kt`, `HtmlCurrencyPicker.kt`) are a separate + hand-written port of `sharedUi/ui/PriceScreen.kt` rather than a shared + one. Not what's deployed to production (`webApp`'s wasmJs build is); + exists for comparing the two approaches and via + `website/serve-local.sh`. - `iosApp/` — a native SwiftUI app (shared across iOS and macOS via `#if os(macOS)`), **not** Compose. It talks to `shared` through a hand-written bridge (see "Core architecture" below), not by rendering @@ -53,8 +71,10 @@ unused KMP-wizard scaffolding, not load-bearing. (`ConverterUiState.xyz()` extension functions) shared between Compose and the iOS bridge. **Read the localization note below before adding anything here that produces user-facing text.** -- `ui/PriceScreen.kt` — the actual Compose UI. Used as-is by Android, - Desktop, and Web (same composable, three renderers). +- `sharedUi/src/commonMain/.../ui/PriceScreen.kt` — the actual Compose UI. + Used as-is by Android, Desktop, and `webApp` (same composable, three + renderers). `webHtmlApp` has its own separate Compose HTML port of this + screen instead (see "Module layout" above). - iOS/macOS **doesn't** use Compose. The chain is: `PriceViewModel` (Kotlin) → `IosPriceViewModel`/`IosConverterState` (`shared/src/appleMain/.../IosPriceViewModel.kt`, a flattened Map-free @@ -92,6 +112,19 @@ the pattern to copy: it returns a locale-formatted `String?` (using locale-aware formatting via platform APIs, not translated text), and each UI layer wraps it with its own localized "Updated %1$s" string. +`webHtmlApp` is the one UI layer that deliberately breaks this pattern: it +can't call `stringResource()` because moko-resources' Compose integration +(`moko-resourcesCompose`) pulls in `compose.foundation`/`compose.ui` +transitively - exactly the dependency `webHtmlApp` exists to avoid (see +"Module layout" above). Its `Strings.kt` is instead **generated** at build +time (`:webHtmlApp:generateStrings`, in `webHtmlApp/build.gradle.kts`) by +parsing `shared/.../moko-resources/base/strings.xml` directly, so that file +stays the single source of truth instead of a manually maintained, +silently driftable duplicate - edit the XML, not the generated file. Only +covers the base (English) locale, since it skips moko's per-locale +resolution entirely. Not a template to follow elsewhere; a one-off +tradeoff specific to that module. + ## Locale-aware formatting `domain/NumberFormat.kt` (digit grouping, decimal separator) and @@ -164,14 +197,17 @@ site (nav, hero, download section) via `/app/`. - `website/app/` is git-ignored — it's build output, generated fresh by CI (and locally by the script below), never committed. -- To reproduce the production layout locally (site + web app together, - `/app/` links working): `./website/serve-local.sh [port]`. It builds the - wasmJs distribution, copies it into `website/app/`, and serves - `website/` with `python3 -m http.server`. +- To try the site + web app together locally (`/app/` links working): + `./website/serve-local.sh [port]`. It builds `webHtmlApp`'s Compose + HTML (DOM) distribution, copies it into `website/app/`, and serves + `website/` with `python3 -m http.server` — note this no longer matches + what the Pages workflow actually deploys (still the Skia `webApp` + wasmJs build), so it's for trying the DOM build, not reproducing + production. - If you change the Pages workflow, remember `paths:` in the trigger - includes `webApp/**`/`shared/**`/Gradle files, not just `website/**` — - a shared-code change that affects the web app should also redeploy the - site. + includes `webApp/**`/`shared/**`/`sharedUi/**`/Gradle files, not just + `website/**` — a shared-code change that affects the web app should + also redeploy the site. ## Testing and verification @@ -195,6 +231,10 @@ site (nav, hero, download section) via `/app/`. `--headless=new --disable-gpu-sandbox --use-gl=angle --use-angle=swiftshader --enable-unsafe-swiftshader --ignore-gpu-blocklist`. This is also the most practical way to verify a Compose UI change at all, since there's no headless Android/Desktop runner set up here. +- `webHtmlApp` is the exception to the above: it renders real DOM, so + plain `--headless=new --dump-dom` (no WebGL/GPU flags needed) shows + actual inspectable HTML. Build/serve it with + `./gradlew :webHtmlApp:jsBrowserDistribution` and a static file server. - Interactive verification of the native macOS build via AppleScript/System Events GUI scripting works but is genuinely flaky — stale processes can linger across launches, the accessibility tree doesn't always reflect diff --git a/README.md b/README.md index 0e437b4..c3c20a6 100644 --- a/README.md +++ b/README.md @@ -71,13 +71,15 @@ options: - Desktop app: - Hot reload: `./gradlew :desktopApp:hotRun --auto` - Standard run: `./gradlew :desktopApp:run` -- Web app: +- Web app (Compose Multiplatform UI, Skia-rendered - what's deployed to production): - Wasm target (faster, modern browsers): `./gradlew :webApp:wasmJsBrowserDevelopmentRun` - JS target (slower, supports older browsers): `./gradlew :webApp:jsBrowserDevelopmentRun` +- Web app, DOM alternative (Compose HTML UI, renders real DOM instead of Skia/canvas - not what's + deployed to production): `./gradlew :webHtmlApp:jsBrowserDevelopmentRun` - iOS app: open the [/iosApp](./iosApp) directory in Xcode and run it from there. -- [Website](./website) (with the web app built and served at `/app/`, matching production): - `./website/serve-local.sh` (serves at http://localhost:8000 by default; pass a port number to - override) +- [Website](./website) with the web app built and served at `/app/` (the `webHtmlApp` DOM build, + not the one production actually deploys): `./website/serve-local.sh` (serves at + http://localhost:8000 by default; pass a port number to override) ### Running tests diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index f4a1f2b..f16ddad 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -11,7 +11,7 @@ kotlin { } } dependencies { - implementation(project(":shared")) + implementation(project(":sharedUi")) implementation(libs.androidx.activity.compose) diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index d305b03..e4e28c3 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -7,7 +7,7 @@ plugins { } dependencies { - implementation(project(":shared")) + implementation(project(":sharedUi")) implementation(compose.desktop.currentOs) implementation(libs.kotlinx.coroutinesSwing) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3b721bf..0903454 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,6 +34,7 @@ androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", vers androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" } +androidx-lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle" } androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" } @@ -41,6 +42,7 @@ compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", v compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" } compose-materialIconsExtended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "composeMaterialIcons" } compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" } +compose-html-core = { module = "org.jetbrains.compose.html:html-core", version.ref = "composeMultiplatform" } compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" } moko-resources = { module = "dev.icerock.moko:resources", version.ref = "mokoResources" } moko-resourcesCompose = { module = "dev.icerock.moko:resources-compose", version.ref = "mokoResources" } diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 6689a00..f8f2f5e 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -274,7 +274,6 @@ struct ContentView: View { unselectedCurrencies: state.unselectedCurrencies, pricedCurrencyCodes: state.pricedCurrencyCodes, sourceName: state.sourceName, - localeCurrencyCode: state.localeCurrencyCode, selectedCount: state.selectedCurrencyCodes.count, onToggle: { viewModel.onFiatCurrencyToggled($0) }, onReset: { viewModel.onSelectedCurrenciesReset() } @@ -404,7 +403,7 @@ private struct NumericField: View { } private func currencyFieldLabel(for code: String) -> String { - if let flag = CurrencyFlagKt.currencyFlagEmoji(code: code) { + if let flag = CurrencyFlagKt.currencyFlagEmoji(code: code, supportsFlagEmoji: SystemCurrencies_appleKt.supportsFlagEmoji()) { return "\(flag) \(code)" } return code diff --git a/iosApp/iosApp/CurrencyPickerSheet.swift b/iosApp/iosApp/CurrencyPickerSheet.swift index eaf6987..a0c2d5b 100644 --- a/iosApp/iosApp/CurrencyPickerSheet.swift +++ b/iosApp/iosApp/CurrencyPickerSheet.swift @@ -7,7 +7,6 @@ struct CurrencyPickerSheet: View { let unselectedCurrencies: [CurrencyInfo] let pricedCurrencyCodes: [String] let sourceName: String - let localeCurrencyCode: String? let selectedCount: Int let onToggle: (String) -> Void let onReset: () -> Void @@ -142,19 +141,11 @@ struct CurrencyPickerSheet: View { } private func currencyLabel(for info: CurrencyInfo) -> String { - let text: String - if info.code == localeCurrencyCode { - text = IosLocalizationKt.localizedFormattedString( - resource: MR.strings.shared.currency_option_label_local, - args: [info.code, info.displayName] - ) - } else { - text = IosLocalizationKt.localizedFormattedString( - resource: MR.strings.shared.currency_option_label, - args: [info.code, info.displayName] - ) - } - if let flag = CurrencyFlagKt.currencyFlagEmoji(code: info.code) { + let text = IosLocalizationKt.localizedFormattedString( + resource: MR.strings.shared.currency_option_label, + args: [info.code, info.displayName] + ) + if let flag = CurrencyFlagKt.currencyFlagEmoji(code: info.code, supportsFlagEmoji: SystemCurrencies_appleKt.supportsFlagEmoji()) { return "\(flag) \(text)" } return text diff --git a/kotlin-js-store/wasm/yarn.lock b/kotlin-js-store/wasm/yarn.lock index 249cfeb..48ed79a 100644 --- a/kotlin-js-store/wasm/yarn.lock +++ b/kotlin-js-store/wasm/yarn.lock @@ -7,11 +7,6 @@ resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273" integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg== -"@js-joda/timezone@2.25.1": - version "2.25.1" - resolved "https://registry.yarnpkg.com/@js-joda/timezone/-/timezone-2.25.1.tgz#4628f18d51af1b197a410834d344418f48a8a264" - integrity sha512-s79ts8bXrWqM9dIBKc0AdgGuAUFpu9gmzYhOCPHJlks/Sf7FSbJHRauWlFYUwjSTZevimqthEvJycrwrVz5m4g== - "@messageformat/core@3.1.0": version "3.1.0" resolved "https://registry.yarnpkg.com/@messageformat/core/-/core-3.1.0.tgz#d4d2f5c3555228a6b5980b122a02b53dfc6458bd" diff --git a/kotlin-js-store/yarn.lock b/kotlin-js-store/yarn.lock index 30fecbc..9206275 100644 --- a/kotlin-js-store/yarn.lock +++ b/kotlin-js-store/yarn.lock @@ -63,11 +63,6 @@ resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273" integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg== -"@js-joda/timezone@2.25.1": - version "2.25.1" - resolved "https://registry.yarnpkg.com/@js-joda/timezone/-/timezone-2.25.1.tgz#4628f18d51af1b197a410834d344418f48a8a264" - integrity sha512-s79ts8bXrWqM9dIBKc0AdgGuAUFpu9gmzYhOCPHJlks/Sf7FSbJHRauWlFYUwjSTZevimqthEvJycrwrVz5m4g== - "@jsonjoy.com/base64@17.67.0": version "17.67.0" resolved "https://registry.yarnpkg.com/@jsonjoy.com/base64/-/base64-17.67.0.tgz#7eeda3cb41138d77a90408fd2e42b2aba10576d7" diff --git a/settings.gradle.kts b/settings.gradle.kts index 9e8a660..054b35a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,4 +30,6 @@ dependencyResolutionManagement { include(":androidApp") include(":desktopApp") include(":shared") -include(":webApp") \ No newline at end of file +include(":sharedUi") +include(":webApp") +include(":webHtmlApp") \ No newline at end of file diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 4ca110a..6027a16 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -4,8 +4,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.androidMultiplatformLibrary) - alias(libs.plugins.composeMultiplatform) - alias(libs.plugins.composeCompiler) alias(libs.plugins.kotlinSerialization) alias(libs.plugins.mokoResources) alias(libs.plugins.sqldelight) @@ -62,28 +60,22 @@ kotlin { sourceSets { androidMain.dependencies { - implementation(libs.compose.uiToolingPreview) - implementation(libs.compose.uiTooling) implementation(libs.ktor.client.okhttp) implementation(libs.sqldelight.androidDriver) } commonMain.dependencies { - implementation(libs.compose.runtime) - implementation(libs.compose.foundation) - implementation(libs.compose.material3) - implementation(libs.compose.materialIconsExtended) - implementation(libs.compose.ui) - implementation(libs.compose.uiToolingPreview) api(libs.moko.resources) - api(libs.moko.resourcesCompose) - implementation(libs.androidx.lifecycle.viewmodelCompose) - implementation(libs.androidx.lifecycle.runtimeCompose) + // PriceViewModel's own supertype and CurrencyConverter's BigDecimal parameters are + // part of :shared's public API, so consumers that touch those types directly (e.g. + // webHtmlApp's Compose HTML screens) need them on their own compile classpath too - + // api() (not implementation()) is what makes Gradle expose them transitively. + api(libs.androidx.lifecycle.viewmodel) + api(libs.bignum) implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.serialization.json) implementation(libs.ktor.client.core) implementation(libs.ktor.client.contentNegotiation) implementation(libs.ktor.serialization.kotlinxJson) - implementation(libs.bignum) implementation(libs.sqldelight.runtime) } commonTest.dependencies { @@ -112,10 +104,6 @@ kotlin { } } -dependencies { - androidRuntimeClasspath(libs.compose.uiTooling) -} - multiplatformResources { resourcesPackage.set("xyz.tyiu.satsprice.shared") } diff --git a/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/IosPriceViewModel.kt b/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/IosPriceViewModel.kt index 7c59a8e..5fdf0c1 100644 --- a/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/IosPriceViewModel.kt +++ b/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/IosPriceViewModel.kt @@ -26,7 +26,6 @@ data class IosConverterState( val unselectedCurrencies: List, val selectedCurrencyCodes: List, val pricedCurrencyCodes: List, - val localeCurrencyCode: String?, val defaultCurrencyCode: String, val sourceName: String, val isManualSource: Boolean, @@ -81,7 +80,6 @@ private fun ConverterUiState.toIosState(): IosConverterState = IosConverterState unselectedCurrencies = unselectedCurrencies(), selectedCurrencyCodes = selectedFiatCurrencies, pricedCurrencyCodes = pricedCurrencyCodes.toList(), - localeCurrencyCode = localeCurrencyCode, defaultCurrencyCode = defaultCurrencyCode, sourceName = sourceName, isManualSource = isManualSource, diff --git a/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/ui/Dimensions.apple.kt b/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/ui/Dimensions.apple.kt deleted file mode 100644 index 54a1839..0000000 --- a/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/ui/Dimensions.apple.kt +++ /dev/null @@ -1,8 +0,0 @@ -package xyz.tyiu.satsprice.ui - -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp - -// PriceScreen.kt is unused on iOS (native SwiftUI is used instead), but this module still -// needs an actual to satisfy the expect declaration for this target. -actual val screenHorizontalPadding: Dp = 16.dp diff --git a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/CurrencyFlag.kt b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/CurrencyFlag.kt index e1c5ff4..b7cdfcb 100644 --- a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/CurrencyFlag.kt +++ b/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/CurrencyFlag.kt @@ -36,11 +36,14 @@ internal fun issuingCountryCodes(code: String): List { * A country flag emoji for [code], or null when there's no flag to show — either because [code] * isn't tied to any country, because it's shared by more than [MAX_FLAGS_PER_CURRENCY] countries * (too visually noisy to show side by side), or because [supportsFlagEmoji] says the platform - * can't render one anyway (Compose for Web's canvas-based text renderer has no color-emoji font - * to fall back to, so a flag's regional-indicator codepoints draw as empty boxes there). + * can't render one anyway. [supportsFlagEmoji] defaults to the expect/actual of the same name, + * which is false for Compose Multiplatform's canvas-based Skia renderer on web (no bundled or + * system color-emoji font to fall back to, so a flag's regional-indicator codepoints draw as + * empty boxes there) - callers that render through a real DOM text node instead (e.g. Compose + * HTML) can pass `true` explicitly, since the browser's own font stack handles it fine. */ -fun currencyFlagEmoji(code: String): String? { - if (!supportsFlagEmoji()) return null +fun currencyFlagEmoji(code: String, supportsFlagEmoji: Boolean = supportsFlagEmoji()): String? { + if (!supportsFlagEmoji) return null val regionCodes = issuingCountryCodes(code) if (regionCodes.isEmpty() || regionCodes.size > MAX_FLAGS_PER_CURRENCY) return null return regionCodes.joinToString(" ") { regionFlagEmoji(it) } diff --git a/shared/src/commonMain/moko-resources/base/strings.xml b/shared/src/commonMain/moko-resources/base/strings.xml index 2129553..7797437 100644 --- a/shared/src/commonMain/moko-resources/base/strings.xml +++ b/shared/src/commonMain/moko-resources/base/strings.xml @@ -10,7 +10,6 @@ Current Currency Not priced by %1$s %1$s - %2$s - %1$s - %2$s Options for %1$s %1$s to Sats Done diff --git a/sharedUi/build.gradle.kts b/sharedUi/build.gradle.kts new file mode 100644 index 0000000..4de5639 --- /dev/null +++ b/sharedUi/build.gradle.kts @@ -0,0 +1,61 @@ +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidMultiplatformLibrary) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) +} + +// The Skia-based Compose Multiplatform UI (App()/PriceScreen()) that androidApp, desktopApp, +// and webApp render. Split out of :shared so :shared itself stays free of compose.ui - the +// Compose Multiplatform Gradle plugin bundles the several-MB Skiko web runtime into *any* +// js/wasmJs target whose resolved dependency graph contains org.jetbrains.compose.ui:ui +// anywhere, and webHtmlApp's DOM-only build needs to avoid that. +kotlin { + jvm() + + js { + browser() + } + + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + browser() + } + + android { + namespace = "xyz.tyiu.satsprice.sharedui" + compileSdk = libs.versions.android.compileSdk.get().toInt() + minSdk = libs.versions.android.minSdk.get().toInt() + + compilerOptions { + jvmTarget = JvmTarget.JVM_11 + } + } + + sourceSets { + androidMain.dependencies { + implementation(libs.compose.uiToolingPreview) + implementation(libs.compose.uiTooling) + } + commonMain.dependencies { + api(project(":shared")) + + implementation(libs.compose.runtime) + implementation(libs.compose.foundation) + implementation(libs.compose.material3) + implementation(libs.compose.materialIconsExtended) + implementation(libs.compose.ui) + implementation(libs.compose.uiToolingPreview) + implementation(libs.moko.resourcesCompose) + implementation(libs.androidx.lifecycle.viewmodelCompose) + implementation(libs.androidx.lifecycle.runtimeCompose) + } + } +} + +dependencies { + androidRuntimeClasspath(libs.compose.uiTooling) +} diff --git a/shared/src/androidMain/kotlin/xyz/tyiu/satsprice/ui/Dimensions.android.kt b/sharedUi/src/androidMain/kotlin/xyz/tyiu/satsprice/ui/Dimensions.android.kt similarity index 100% rename from shared/src/androidMain/kotlin/xyz/tyiu/satsprice/ui/Dimensions.android.kt rename to sharedUi/src/androidMain/kotlin/xyz/tyiu/satsprice/ui/Dimensions.android.kt diff --git a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/App.kt b/sharedUi/src/commonMain/kotlin/xyz/tyiu/satsprice/App.kt similarity index 100% rename from shared/src/commonMain/kotlin/xyz/tyiu/satsprice/App.kt rename to sharedUi/src/commonMain/kotlin/xyz/tyiu/satsprice/App.kt diff --git a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/Dimensions.kt b/sharedUi/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/Dimensions.kt similarity index 100% rename from shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/Dimensions.kt rename to sharedUi/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/Dimensions.kt diff --git a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceScreen.kt b/sharedUi/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceScreen.kt similarity index 98% rename from shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceScreen.kt rename to sharedUi/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceScreen.kt index 1fcb778..7edcb97 100644 --- a/shared/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceScreen.kt +++ b/sharedUi/src/commonMain/kotlin/xyz/tyiu/satsprice/ui/PriceScreen.kt @@ -605,7 +605,6 @@ private fun CurrencyPickerScreen( isSelected = true, isPriced = state.isPriced(currentCurrency.code), sourceName = state.sourceName, - localeCurrencyCode = state.localeCurrencyCode, onClick = null, ) } @@ -625,7 +624,6 @@ private fun CurrencyPickerScreen( isSelected = true, isPriced = state.isPriced(info.code), sourceName = state.sourceName, - localeCurrencyCode = state.localeCurrencyCode, onClick = { onToggle(info.code) }, ) } @@ -649,7 +647,6 @@ private fun CurrencyPickerScreen( isSelected = false, isPriced = true, sourceName = state.sourceName, - localeCurrencyCode = state.localeCurrencyCode, onClick = { onToggle(info.code) }, ) } @@ -671,7 +668,6 @@ private fun CurrencyPickerScreen( isSelected = false, isPriced = false, sourceName = state.sourceName, - localeCurrencyCode = state.localeCurrencyCode, onClick = { onToggle(info.code) }, ) } @@ -689,14 +685,9 @@ private fun CurrencyRow( isSelected: Boolean, isPriced: Boolean, sourceName: String, - localeCurrencyCode: String?, onClick: (() -> Unit)?, ) { - val text = if (info.code == localeCurrencyCode) { - stringResource(MR.strings.currency_option_label_local, info.code, info.displayName) - } else { - stringResource(MR.strings.currency_option_label, info.code, info.displayName) - } + val text = stringResource(MR.strings.currency_option_label, info.code, info.displayName) val label = currencyFlagEmoji(info.code)?.let { flag -> "$flag $text" } ?: text Row( modifier = Modifier diff --git a/shared/src/jvmMain/kotlin/xyz/tyiu/satsprice/ui/Dimensions.jvm.kt b/sharedUi/src/jvmMain/kotlin/xyz/tyiu/satsprice/ui/Dimensions.jvm.kt similarity index 100% rename from shared/src/jvmMain/kotlin/xyz/tyiu/satsprice/ui/Dimensions.jvm.kt rename to sharedUi/src/jvmMain/kotlin/xyz/tyiu/satsprice/ui/Dimensions.jvm.kt diff --git a/shared/src/webMain/kotlin/xyz/tyiu/satsprice/ui/Dimensions.web.kt b/sharedUi/src/webMain/kotlin/xyz/tyiu/satsprice/ui/Dimensions.web.kt similarity index 100% rename from shared/src/webMain/kotlin/xyz/tyiu/satsprice/ui/Dimensions.web.kt rename to sharedUi/src/webMain/kotlin/xyz/tyiu/satsprice/ui/Dimensions.web.kt diff --git a/webApp/build.gradle.kts b/webApp/build.gradle.kts index 1852c0e..a778316 100644 --- a/webApp/build.gradle.kts +++ b/webApp/build.gradle.kts @@ -21,11 +21,9 @@ kotlin { sourceSets { commonMain.dependencies { - implementation(project(":shared")) + implementation(project(":sharedUi")) implementation(libs.compose.ui) - - implementation(npm("@js-joda/timezone", "2.25.1")) } } } diff --git a/webApp/src/webMain/kotlin/xyz/tyiu/satsprice/main.kt b/webApp/src/webMain/kotlin/xyz/tyiu/satsprice/main.kt index f822270..8e71d95 100644 --- a/webApp/src/webMain/kotlin/xyz/tyiu/satsprice/main.kt +++ b/webApp/src/webMain/kotlin/xyz/tyiu/satsprice/main.kt @@ -3,15 +3,9 @@ package xyz.tyiu.satsprice import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.window.ComposeViewport -@OptIn(ExperimentalWasmJsInterop::class) -@JsModule("@js-joda/timezone") -external object JsJodaTimeZoneModule - -private val jsJodaTz = JsJodaTimeZoneModule - @OptIn(ExperimentalComposeUiApi::class) fun main() { ComposeViewport { App() } -} \ No newline at end of file +} diff --git a/webHtmlApp/build.gradle.kts b/webHtmlApp/build.gradle.kts new file mode 100644 index 0000000..06687aa --- /dev/null +++ b/webHtmlApp/build.gradle.kts @@ -0,0 +1,111 @@ +import org.w3c.dom.Element +import javax.xml.parsers.DocumentBuilderFactory + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) +} + +// webHtmlApp can't use moko-resources' Compose integration (stringResource()) without pulling +// in compose.foundation/ui transitively - exactly what this module exists to avoid (see +// AGENTS.md's "Module layout"). Generating a plain Strings.kt from the same strings.xml moko +// reads keeps that file as the single source of truth without a manually maintained, silently +// driftable duplicate. Only covers the base (English) locale - see AGENTS.md. +val stringsXmlFile = rootProject.file("shared/src/commonMain/moko-resources/base/strings.xml") + +val generateStrings by tasks.registering { + val outputDir = layout.buildDirectory.dir("generated/strings") + // Captured as a plain local (rather than read from the doLast closure directly) so the task + // action doesn't hold a reference to the build script object itself, which the configuration + // cache refuses to serialize. + val xmlFile = stringsXmlFile + inputs.file(xmlFile) + outputs.dir(outputDir) + + doLast { + val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(xmlFile) + val stringNodes = document.getElementsByTagName("string") + val placeholderPattern = Regex("""%(\d+)\$([sd])""") + + fun String.toCamelCase(): String = split("_").mapIndexed { index, word -> + if (index == 0) word else word.replaceFirstChar { it.uppercase() } + }.joinToString("") + + val source = buildString { + appendLine("// GENERATED FILE - do not edit directly.") + appendLine("// Regenerated by :webHtmlApp:generateStrings from") + appendLine("// shared/src/commonMain/moko-resources/base/strings.xml - edit that file instead.") + appendLine("package xyz.tyiu.satsprice") + appendLine() + appendLine("object Strings {") + for (i in 0 until stringNodes.length) { + val element = stringNodes.item(i) as Element + val propertyName = element.getAttribute("name").toCamelCase() + // moko/Android string-resource XML escapes a literal apostrophe as \' inside the + // (unquoted) text content - unescape that before anything else so it doesn't get + // mistaken for a Kotlin escape sequence below. + val rawValue = element.textContent.replace("\\'", "'") + + val placeholders = placeholderPattern.findAll(rawValue) + .map { it.groupValues[1].toInt() to it.groupValues[2] } + .distinct() + .sortedBy { it.first } + .toList() + + // Swap each %N$s/%N$d placeholder for a control-character sentinel first, so the + // literal '$' in "%1$s" itself doesn't get caught by the blanket '$' -> "\$" + // escaping below - then escape, then turn the sentinels into "${argN}". + var withSentinels = rawValue + for ((index, _) in placeholders) { + withSentinels = withSentinels.replace(Regex("""%$index\$[sd]"""), "$index") + } + val escaped = withSentinels + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("$", "\\$") + var templateBody = escaped + for ((index, _) in placeholders) { + templateBody = templateBody.replace("$index", "\${arg$index}") + } + + if (placeholders.isEmpty()) { + appendLine(" const val $propertyName: String = \"$templateBody\"") + } else { + val params = placeholders.joinToString(", ") { (index, type) -> + "arg$index: ${if (type == "d") "Int" else "String"}" + } + appendLine(" fun $propertyName($params): String = \"$templateBody\"") + } + } + appendLine("}") + } + + val outputFile = outputDir.get().file("xyz/tyiu/satsprice/Strings.kt").asFile + outputFile.parentFile.mkdirs() + outputFile.writeText(source) + } +} + +// Compose HTML (org.jetbrains.compose.html:html-core) only publishes js and jvm target +// variants as of Compose Multiplatform 1.11.1 - no wasmJs artifact exists yet, unlike the +// Skia-based Compose Multiplatform UI toolkit webApp uses (which targets both). So this +// comparison module is js-only for now. +kotlin { + js { + browser() + binaries.executable() + } + + sourceSets { + commonMain { + kotlin.srcDir(generateStrings) + dependencies { + implementation(project(":shared")) + + implementation(libs.compose.runtime) + implementation(libs.compose.html.core) + } + } + } +} diff --git a/webHtmlApp/src/webMain/kotlin/xyz/tyiu/satsprice/HtmlApp.kt b/webHtmlApp/src/webMain/kotlin/xyz/tyiu/satsprice/HtmlApp.kt new file mode 100644 index 0000000..22654f1 --- /dev/null +++ b/webHtmlApp/src/webMain/kotlin/xyz/tyiu/satsprice/HtmlApp.kt @@ -0,0 +1,288 @@ +package xyz.tyiu.satsprice + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import org.jetbrains.compose.web.attributes.InputType +import org.jetbrains.compose.web.dom.Button +import org.jetbrains.compose.web.dom.Div +import org.jetbrains.compose.web.dom.H1 +import org.jetbrains.compose.web.dom.Input +import org.jetbrains.compose.web.dom.Option +import org.jetbrains.compose.web.dom.Select +import org.jetbrains.compose.web.dom.Span +import org.jetbrains.compose.web.dom.Text +import xyz.tyiu.satsprice.domain.CurrencyConverter +import xyz.tyiu.satsprice.domain.formatAmount +import xyz.tyiu.satsprice.domain.groupDigits +import xyz.tyiu.satsprice.ui.PriceViewModel +import xyz.tyiu.satsprice.ui.defaultCurrencyRate +import xyz.tyiu.satsprice.ui.exceedsMaxSupply +import xyz.tyiu.satsprice.ui.isPriced +import xyz.tyiu.satsprice.ui.lastUpdatedDateTime +import xyz.tyiu.satsprice.ui.oneCurrencyToSats + +@Composable +fun HtmlApp() { + val viewModel = remember { PriceViewModel() } + val state by viewModel.uiState.collectAsState() + var showCurrencyPicker by remember { mutableStateOf(false) } + + if (showCurrencyPicker) { + HtmlCurrencyPicker( + state = state, + onToggle = viewModel::onFiatCurrencyToggled, + onReset = viewModel::onSelectedCurrenciesReset, + onDone = { showCurrencyPicker = false }, + ) + return + } + + Div({ classes("screen") }) { + H1({ classes("app-title") }) { Text("SatsPrice") } + + // Price source + Div({ classes("card") }) { + Div({ classes("row") }) { + Span({ classes("field-inline-label") }) { Text(Strings.priceSource) } + Select(attrs = { + onChange { event -> viewModel.onSourceSelected(event.target.value) } + }) { + viewModel.availableSources.forEach { name -> + Option(value = name, attrs = { if (name == state.sourceName) attr("selected", "") }) { Text(name) } + } + } + } + + Div({ classes("row") }) { + LabeledInput( + label = Strings.btcToCurrency(state.defaultCurrencyCode), + value = if (state.isManualSource) state.manualRateInput else state.defaultCurrencyRate(), + readOnly = !state.isManualSource, + onValueChange = viewModel::onManualRateChanged, + modifierClass = "grow", + ) + if (!state.isManualSource) { + if (state.isLoading) { + Span({ classes("spinner") }) + } else { + Button(attrs = { + classes("icon-button") + attr("aria-label", Strings.refreshContentDescription) + attr("title", Strings.refreshContentDescription) + onClick { viewModel.refresh() } + }) { Text("↻") } + } + } + } + + if (state.isManualSource) { + LabeledInput( + label = Strings.currencyToSats(state.defaultCurrencyCode), + value = state.manualSatsPerCurrencyInput, + onValueChange = viewModel::onManualSatsPerCurrencyChanged, + ) + } else { + LabeledInput( + label = Strings.currencyToSats(state.defaultCurrencyCode), + value = state.oneCurrencyToSats(), + readOnly = true, + onValueChange = {}, + ) + } + + val statusLine = if (state.isManualSource) { + null + } else { + state.lastUpdatedDateTime()?.let { Strings.updatedStatus(it) } ?: Strings.loadingRatesStatus + } + statusLine?.let { Div({ classes("status") }) { Text(it) } } + } + + state.errorMessage?.let { message -> + Div({ classes("card", "error-banner") }) { + Span { Text(message) } + Button(attrs = { onClick { viewModel.refresh() } }) { Text(Strings.retry) } + } + } + + val exceedsMaxSupply = state.exceedsMaxSupply() + + // Bitcoin + Div({ classes("card") }) { + Div({ classes("section-title") }) { Text(Strings.bitcoinSectionTitle) } + LabeledInput( + label = Strings.satsLabel, + value = state.satsAmount, + isError = exceedsMaxSupply, + onValueChange = viewModel::onSatsAmountChanged, + ) + LabeledInput( + label = Strings.btcLabel, + value = state.btcAmount, + isError = exceedsMaxSupply, + onValueChange = viewModel::onBtcAmountChanged, + ) + if (exceedsMaxSupply) { + val maxSupplyText = groupDigits(formatAmount(CurrencyConverter.MAX_BTC_SUPPLY, 0)) + " BTC" + Div({ classes("warning") }) { Text(Strings.exceedsMaxSupply(maxSupplyText)) } + Button(attrs = { + classes("link-button") + onClick { viewModel.onBtcAmountChanged(formatAmount(CurrencyConverter.MAX_BTC_SUPPLY, 0)) } + }) { Text("Set BTC to $maxSupplyText") } + } + } + + // Currencies + Div({ classes("card") }) { + Div({ classes("row", "section-title-row") }) { + Span({ classes("section-title") }) { Text(Strings.currenciesSectionTitle) } + if (!state.isManualSource) { + Button(attrs = { classes("outlined-button"); onClick { showCurrencyPicker = true } }) { + Text( + if (state.selectedFiatCurrencies.size <= 1) { + Strings.addCurrency + } else { + Strings.currenciesSelectedCount(state.selectedFiatCurrencies.size) + }, + ) + } + } + } + + val displayedCurrencies = if (state.isManualSource) { + listOf(state.defaultCurrencyCode) + } else { + state.selectedFiatCurrencies + } + displayedCurrencies.forEachIndexed { index, code -> + val isPriced = state.isPriced(code) + val fieldLabel = currencyFlagEmoji(code, supportsFlagEmoji = true)?.let { flag -> "$flag $code" } ?: code + Div({ classes("row", "currency-row") }) { + LabeledInput( + label = fieldLabel, + value = if (isPriced) state.fiatAmounts[code].orEmpty() else "", + enabled = isPriced, + isError = !isPriced && !state.isManualSource, + supportingText = if (!isPriced && !state.isManualSource) { + Strings.currencyNotPriced(state.sourceName) + } else { + null + }, + onValueChange = { viewModel.onFiatAmountChanged(code, it) }, + modifierClass = "grow", + ) + if (displayedCurrencies.size > 1) { + val selection = state.selectedFiatCurrencies + CurrencyRowMenu( + code = code, + canMoveUp = index > 0, + canMoveDown = index < selection.lastIndex, + canRemove = code != state.defaultCurrencyCode, + onMoveUp = { viewModel.onFiatCurrenciesReordered(selection.moved(index, index - 1)) }, + onMoveDown = { viewModel.onFiatCurrenciesReordered(selection.moved(index, index + 1)) }, + onMoveToTop = { viewModel.onFiatCurrenciesReordered(selection.moved(index, 0)) }, + onMoveToBottom = { + viewModel.onFiatCurrenciesReordered(selection.moved(index, selection.lastIndex)) + }, + onRemove = { viewModel.onFiatCurrencyToggled(code) }, + ) + } + } + } + } + } +} + +internal fun List.moved(fromIndex: Int, toIndex: Int): List = + toMutableList().apply { add(toIndex, removeAt(fromIndex)) } + +/** + * An anchored popup menu for reordering/removing a currency row, matching Material3's + * DropdownMenu behavior. Compose HTML has no built-in menu primitive, so this is plain DOM: a + * relatively-positioned trigger, an absolutely-positioned popup anchored under it (CSS handles + * the anchoring - no JS position math needed), and a full-viewport backdrop that closes the menu + * on any outside click, matching how the Material3 version dismisses. + */ +@Composable +private fun CurrencyRowMenu( + code: String, + canMoveUp: Boolean, + canMoveDown: Boolean, + canRemove: Boolean, + onMoveUp: () -> Unit, + onMoveDown: () -> Unit, + onMoveToTop: () -> Unit, + onMoveToBottom: () -> Unit, + onRemove: () -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + Div({ classes("menu-anchor") }) { + Button(attrs = { + classes("icon-button") + attr("aria-label", Strings.currencyOptionsContentDescription(code)) + attr("aria-haspopup", "true") + attr("aria-expanded", if (expanded) "true" else "false") + onClick { expanded = true } + }) { Text("⋮") } + + if (expanded) { + Div({ + classes("menu-backdrop") + onClick { expanded = false } + }) + Div({ classes("menu-popup") }) { + MenuItem(Strings.moveCurrencyToTopContentDescription, enabled = canMoveUp) { expanded = false; onMoveToTop() } + MenuItem(Strings.moveCurrencyUpContentDescription, enabled = canMoveUp) { expanded = false; onMoveUp() } + MenuItem(Strings.moveCurrencyDownContentDescription, enabled = canMoveDown) { expanded = false; onMoveDown() } + MenuItem(Strings.moveCurrencyToBottomContentDescription, enabled = canMoveDown) { + expanded = false + onMoveToBottom() + } + if (canRemove) { + MenuItem(Strings.removeCurrencyContentDescription, destructive = true) { expanded = false; onRemove() } + } + } + } + } +} + +@Composable +private fun MenuItem(label: String, enabled: Boolean = true, destructive: Boolean = false, onClick: () -> Unit) { + Button(attrs = { + classes("menu-item") + if (destructive) classes("menu-item-destructive") + if (!enabled) attr("disabled", "") + onClick { onClick() } + }) { Text(label) } +} + +@Composable +internal fun LabeledInput( + label: String, + value: String, + onValueChange: (String) -> Unit, + readOnly: Boolean = false, + enabled: Boolean = true, + isError: Boolean = false, + supportingText: String? = null, + modifierClass: String? = null, +) { + Div({ + classes("field") + modifierClass?.let { classes(it) } + if (isError) classes("field-error") + }) { + Span({ classes("field-label") }) { Text(label) } + Input(type = InputType.Text, attrs = { + value(value) + if (readOnly) attr("readonly", "") + if (!enabled) attr("disabled", "") + onInput { event -> onValueChange(event.value) } + }) + supportingText?.let { Div({ classes("supporting-text") }) { Text(it) } } + } +} diff --git a/webHtmlApp/src/webMain/kotlin/xyz/tyiu/satsprice/HtmlCurrencyPicker.kt b/webHtmlApp/src/webMain/kotlin/xyz/tyiu/satsprice/HtmlCurrencyPicker.kt new file mode 100644 index 0000000..e031042 --- /dev/null +++ b/webHtmlApp/src/webMain/kotlin/xyz/tyiu/satsprice/HtmlCurrencyPicker.kt @@ -0,0 +1,139 @@ +package xyz.tyiu.satsprice + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import org.jetbrains.compose.web.attributes.InputType +import org.jetbrains.compose.web.dom.Button +import org.jetbrains.compose.web.dom.Div +import org.jetbrains.compose.web.dom.H2 +import org.jetbrains.compose.web.dom.Input +import org.jetbrains.compose.web.dom.Span +import org.jetbrains.compose.web.dom.Text +import xyz.tyiu.satsprice.ui.ConverterUiState +import xyz.tyiu.satsprice.ui.currentCurrency +import xyz.tyiu.satsprice.ui.isPriced +import xyz.tyiu.satsprice.ui.selectedOtherCurrencies +import xyz.tyiu.satsprice.ui.unselectedCurrencies + +@Composable +internal fun HtmlCurrencyPicker( + state: ConverterUiState, + onToggle: (String) -> Unit, + onReset: () -> Unit, + onDone: () -> Unit, +) { + var showResetConfirmation by remember { mutableStateOf(false) } + + if (showResetConfirmation) { + Div({ classes("modal-overlay") }) { + Div({ classes("modal") }) { + H2 { Text(Strings.resetSelectedCurrenciesConfirmationTitle) } + Div { Text(Strings.resetSelectedCurrenciesConfirmationMessage(state.defaultCurrencyCode)) } + Div({ classes("row", "modal-actions") }) { + Button(attrs = { onClick { showResetConfirmation = false } }) { Text(Strings.cancel) } + Button(attrs = { + classes("destructive-button") + onClick { + showResetConfirmation = false + onReset() + } + }) { Text(Strings.resetSelectedCurrenciesButton) } + } + } + } + } + + Div({ classes("screen") }) { + Div({ classes("row", "section-title-row") }) { + H2 { Text(Strings.currenciesSectionTitle) } + Div({ classes("row") }) { + if (state.selectedFiatCurrencies.size > 1) { + Button(attrs = { onClick { showResetConfirmation = true } }) { + Text(Strings.resetSelectedCurrenciesButton) + } + } + Button(attrs = { onClick { onDone() } }) { Text(Strings.done) } + } + } + + var searchQuery by remember { mutableStateOf("") } + Input(type = InputType.Text, attrs = { + classes("search-input") + attr("placeholder", Strings.searchCurrenciesPlaceholder) + value(searchQuery) + onInput { event -> searchQuery = event.value } + }) + + val currentCurrency = state.currentCurrency() + val selectedOthers = state.selectedOtherCurrencies().filter { matchesCurrencySearch(it, searchQuery) } + val unselected = state.unselectedCurrencies().filter { matchesCurrencySearch(it, searchQuery) } + val (unselectedPriced, unselectedUnpriced) = unselected.partition { state.isPriced(it.code) } + + if (matchesCurrencySearch(currentCurrency, searchQuery)) { + CurrencySection(Strings.currentCurrencySectionTitle) { + CurrencyRow(currentCurrency, isSelected = true, isPriced = state.isPriced(currentCurrency.code), state, onClick = null) + } + } + if (selectedOthers.isNotEmpty()) { + CurrencySection(Strings.selectedCurrenciesSectionTitle) { + selectedOthers.forEach { info -> + CurrencyRow(info, isSelected = true, isPriced = state.isPriced(info.code), state) { onToggle(info.code) } + } + } + } + if (unselectedPriced.isNotEmpty()) { + CurrencySection(Strings.pricedCurrenciesSectionTitle) { + unselectedPriced.forEach { info -> + CurrencyRow(info, isSelected = false, isPriced = true, state) { onToggle(info.code) } + } + } + } + if (unselectedUnpriced.isNotEmpty()) { + CurrencySection(Strings.unpricedCurrenciesSectionTitle) { + unselectedUnpriced.forEach { info -> + CurrencyRow(info, isSelected = false, isPriced = false, state) { onToggle(info.code) } + } + } + } + } +} + +@Composable +private fun CurrencySection(title: String, content: @Composable () -> Unit) { + Div({ classes("currency-section") }) { + Div({ classes("section-header") }) { Text(title.uppercase()) } + content() + } +} + +@Composable +private fun CurrencyRow( + info: CurrencyInfo, + isSelected: Boolean, + isPriced: Boolean, + state: ConverterUiState, + onClick: (() -> Unit)?, +) { + val text = Strings.currencyOptionLabel(info.code, info.displayName) + val label = currencyFlagEmoji(info.code, supportsFlagEmoji = true)?.let { flag -> "$flag $text" } ?: text + Div({ + classes("row", "picker-row") + if (onClick != null) { + classes("clickable") + onClick { onClick() } + } + }) { + Div { + Div { Text(label) } + if (!isPriced) { + Div({ classes("supporting-text", "error-text") }) { + Text(Strings.currencyNotPriced(state.sourceName)) + } + } + } + if (isSelected) Span({ classes("check-mark") }) { Text("✓") } + } +} diff --git a/webHtmlApp/src/webMain/kotlin/xyz/tyiu/satsprice/main.kt b/webHtmlApp/src/webMain/kotlin/xyz/tyiu/satsprice/main.kt new file mode 100644 index 0000000..d3a0698 --- /dev/null +++ b/webHtmlApp/src/webMain/kotlin/xyz/tyiu/satsprice/main.kt @@ -0,0 +1,9 @@ +package xyz.tyiu.satsprice + +import org.jetbrains.compose.web.renderComposable + +fun main() { + renderComposable(rootElementId = "root") { + HtmlApp() + } +} diff --git a/webHtmlApp/src/webMain/resources/favicon-192.png b/webHtmlApp/src/webMain/resources/favicon-192.png new file mode 100644 index 0000000..27f2460 Binary files /dev/null and b/webHtmlApp/src/webMain/resources/favicon-192.png differ diff --git a/webHtmlApp/src/webMain/resources/favicon.ico b/webHtmlApp/src/webMain/resources/favicon.ico new file mode 100644 index 0000000..d0c7ef7 Binary files /dev/null and b/webHtmlApp/src/webMain/resources/favicon.ico differ diff --git a/webHtmlApp/src/webMain/resources/index.html b/webHtmlApp/src/webMain/resources/index.html new file mode 100644 index 0000000..e7ae23c --- /dev/null +++ b/webHtmlApp/src/webMain/resources/index.html @@ -0,0 +1,16 @@ + + + + + + SatsPrice + + + + + + +
+ + + diff --git a/webHtmlApp/src/webMain/resources/styles.css b/webHtmlApp/src/webMain/resources/styles.css new file mode 100644 index 0000000..3226c5d --- /dev/null +++ b/webHtmlApp/src/webMain/resources/styles.css @@ -0,0 +1,314 @@ +:root { + --bg: #fef7ff; + --surface: #ffffff; + --surface-container: #f3edf7; + --on-surface: #1d1b20; + --on-surface-variant: #49454f; + --primary: #6750a4; + --outline: #79747e; + --error: #b3261e; + --error-container: #f9dedc; + --on-error-container: #410e0b; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #141218; + --surface: #211f26; + --surface-container: #2b2930; + --on-surface: #e6e0e9; + --on-surface-variant: #cac4d0; + --primary: #d0bcff; + --outline: #948f99; + --error: #f2b8b5; + --error-container: #8c1d18; + --on-error-container: #f9dedc; + } +} + +html, body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--on-surface); + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} + +.screen { + max-width: 480px; + margin: 0 auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 16px; + box-sizing: border-box; +} + +.app-title { + margin: 8px; + font-size: 1.5rem; +} + +.card { + background: var(--surface-container); + border-radius: 12px; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.row { + display: flex; + align-items: center; + gap: 8px; +} + +.section-title { + font-weight: 600; + font-size: 1.05rem; +} + +.section-title-row { + justify-content: space-between; +} + +.field { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; +} + +.field.grow { + flex: 1; +} + +.field-label { + font-size: 0.75rem; + color: var(--on-surface-variant); +} + +.field-inline-label { + font-size: 0.9rem; + color: var(--on-surface-variant); +} + +.field input { + font-size: 1rem; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--outline); + background: var(--surface); + color: var(--on-surface); + box-sizing: border-box; +} + +.field input:disabled { + opacity: 0.6; +} + +.field-error input { + border-color: var(--error); +} + +.supporting-text { + font-size: 0.75rem; + color: var(--on-surface-variant); +} + +.error-text { + color: var(--error); +} + +.status { + font-size: 0.8rem; + color: var(--on-surface-variant); +} + +.error-banner { + flex-direction: row; + justify-content: space-between; + align-items: center; + background: var(--error-container); + color: var(--on-error-container); +} + +.warning { + color: var(--error); + font-size: 0.85rem; +} + +.currency-row { + align-items: flex-start; +} + +.menu-anchor { + position: relative; + margin-top: 20px; +} + +.menu-backdrop { + position: fixed; + inset: 0; + z-index: 15; +} + +.menu-popup { + position: absolute; + top: 100%; + right: 0; + z-index: 16; + margin-top: 4px; + min-width: 200px; + background: var(--surface); + border-radius: 8px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3); + padding: 4px; + display: flex; + flex-direction: column; +} + +.menu-item { + border: none; + background: transparent; + color: var(--on-surface); + text-align: left; + padding: 10px 12px; + border-radius: 6px; + font-size: 0.9rem; + width: 100%; + box-sizing: border-box; +} + +.menu-item:not(:disabled):hover { + background: rgba(0, 0, 0, 0.08); +} + +.menu-item-destructive { + color: var(--error); +} + +button { + font-family: inherit; + cursor: pointer; +} + +button:disabled { + cursor: default; + opacity: 0.35; +} + +.icon-button { + border: none; + background: transparent; + color: var(--on-surface); + font-size: 1.1rem; + width: 32px; + height: 32px; + border-radius: 50%; +} + +.icon-button:not(:disabled):hover { + background: rgba(0, 0, 0, 0.08); +} + +.outlined-button, .link-button, .destructive-button, .modal-actions button, .section-title-row button { + border: 1px solid var(--outline); + background: transparent; + color: var(--primary); + border-radius: 20px; + padding: 8px 16px; + font-size: 0.9rem; +} + +.link-button { + border: none; + padding: 0; + text-decoration: underline; + align-self: flex-start; +} + +.destructive-button { + color: var(--error); + border-color: var(--error); +} + +.spinner { + width: 20px; + height: 20px; + border: 2px solid var(--outline); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.search-input { + font-size: 1rem; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--outline); + background: var(--surface); + color: var(--on-surface); + box-sizing: border-box; +} + +.currency-section { + display: flex; + flex-direction: column; + gap: 4px; +} + +.section-header { + font-size: 0.75rem; + letter-spacing: 0.05em; + color: var(--primary); + font-weight: 600; + margin-top: 8px; +} + +.picker-row { + justify-content: space-between; + padding: 10px 4px; + border-bottom: 1px solid var(--surface-container); +} + +.picker-row.clickable { + cursor: pointer; +} + +.picker-row.clickable:hover { + background: rgba(0, 0, 0, 0.04); +} + +.check-mark { + color: var(--primary); +} + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} + +.modal { + background: var(--surface); + border-radius: 12px; + padding: 20px; + max-width: 360px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.modal-actions { + justify-content: flex-end; +} diff --git a/website/serve-local.sh b/website/serve-local.sh index e05f4fd..f6f2f1b 100755 --- a/website/serve-local.sh +++ b/website/serve-local.sh @@ -7,12 +7,12 @@ PORT="${1:-8000}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(dirname "$SCRIPT_DIR")" -echo "Building web app (wasmJs)..." -"$REPO_ROOT/gradlew" -p "$REPO_ROOT" :webApp:wasmJsBrowserDistribution +echo "Building web app (webHtmlApp)..." +"$REPO_ROOT/gradlew" -p "$REPO_ROOT" :webHtmlApp:jsBrowserDistribution echo "Copying web app into website/app/..." mkdir -p "$SCRIPT_DIR/app" -cp -r "$REPO_ROOT/webApp/build/dist/wasmJs/productionExecutable/." "$SCRIPT_DIR/app/" +cp -r "$REPO_ROOT/webHtmlApp/build/dist/js/productionExecutable/." "$SCRIPT_DIR/app/" echo "Serving website at http://localhost:$PORT/ (web app at http://localhost:$PORT/app/)" cd "$SCRIPT_DIR" && python3 -m http.server "$PORT"