Merge pull request #9 from tyiu/compose-html
Deploy website to GitHub Pages / deploy (push) Canceled after 0s
Deploy website to GitHub Pages / deploy (push) Canceled after 0s
Add Compose HTML UI for web app
This commit is contained in:
@@ -6,6 +6,7 @@ on:
|
||||
paths:
|
||||
- "website/**"
|
||||
- "shared/**"
|
||||
- "sharedUi/**"
|
||||
- "webApp/**"
|
||||
- "gradle/**"
|
||||
- "gradlew"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ kotlin {
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
implementation(project(":shared"))
|
||||
implementation(project(":sharedUi"))
|
||||
|
||||
implementation(libs.androidx.activity.compose)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ plugins {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":shared"))
|
||||
implementation(project(":sharedUi"))
|
||||
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(libs.kotlinx.coroutinesSwing)
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
+3
-1
@@ -30,4 +30,6 @@ dependencyResolutionManagement {
|
||||
include(":androidApp")
|
||||
include(":desktopApp")
|
||||
include(":shared")
|
||||
include(":webApp")
|
||||
include(":sharedUi")
|
||||
include(":webApp")
|
||||
include(":webHtmlApp")
|
||||
+6
-18
@@ -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")
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ data class IosConverterState(
|
||||
val unselectedCurrencies: List<CurrencyInfo>,
|
||||
val selectedCurrencyCodes: List<String>,
|
||||
val pricedCurrencyCodes: List<String>,
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
@@ -36,11 +36,14 @@ internal fun issuingCountryCodes(code: String): List<String> {
|
||||
* 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) }
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
<string name="current_currency_section_title">Current Currency</string>
|
||||
<string name="currency_not_priced">Not priced by %1$s</string>
|
||||
<string name="currency_option_label">%1$s - %2$s</string>
|
||||
<string name="currency_option_label_local">%1$s - %2$s</string>
|
||||
<string name="currency_options_content_description">Options for %1$s</string>
|
||||
<string name="currency_to_sats">%1$s to Sats</string>
|
||||
<string name="done">Done</string>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+1
-10
@@ -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
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String>.moved(fromIndex: Int, toIndex: Int): List<String> =
|
||||
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) } }
|
||||
}
|
||||
}
|
||||
@@ -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("✓") }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package xyz.tyiu.satsprice
|
||||
|
||||
import org.jetbrains.compose.web.renderComposable
|
||||
|
||||
fun main() {
|
||||
renderComposable(rootElementId = "root") {
|
||||
HtmlApp()
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SatsPrice</title>
|
||||
<meta name="description" content="SatsPrice converts Bitcoin, satoshis, and fiat currency in real time — Compose HTML build.">
|
||||
<link type="text/css" rel="stylesheet" href="styles.css">
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="icon" type="image/png" href="favicon-192.png" sizes="192x192">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="application/javascript" src="webHtmlApp.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user