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:
|
paths:
|
||||||
- "website/**"
|
- "website/**"
|
||||||
- "shared/**"
|
- "shared/**"
|
||||||
|
- "sharedUi/**"
|
||||||
- "webApp/**"
|
- "webApp/**"
|
||||||
- "gradle/**"
|
- "gradle/**"
|
||||||
- "gradlew"
|
- "gradlew"
|
||||||
|
|||||||
@@ -24,10 +24,28 @@ Kotlin) implementation — `main` is now this Kotlin Multiplatform project.
|
|||||||
|
|
||||||
## Module layout
|
## Module layout
|
||||||
|
|
||||||
- `shared/` — the actual app: UI, view models, data sources, domain logic.
|
- `shared/` — view models, data sources, domain logic. No Compose UI
|
||||||
Almost everything happens here.
|
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
|
- `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
|
- `iosApp/` — a native SwiftUI app (shared across iOS and macOS via
|
||||||
`#if os(macOS)`), **not** Compose. It talks to `shared` through a
|
`#if os(macOS)`), **not** Compose. It talks to `shared` through a
|
||||||
hand-written bridge (see "Core architecture" below), not by rendering
|
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
|
(`ConverterUiState.xyz()` extension functions) shared between Compose and
|
||||||
the iOS bridge. **Read the localization note below before adding
|
the iOS bridge. **Read the localization note below before adding
|
||||||
anything here that produces user-facing text.**
|
anything here that produces user-facing text.**
|
||||||
- `ui/PriceScreen.kt` — the actual Compose UI. Used as-is by Android,
|
- `sharedUi/src/commonMain/.../ui/PriceScreen.kt` — the actual Compose UI.
|
||||||
Desktop, and Web (same composable, three renderers).
|
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:
|
- iOS/macOS **doesn't** use Compose. The chain is:
|
||||||
`PriceViewModel` (Kotlin) → `IosPriceViewModel`/`IosConverterState`
|
`PriceViewModel` (Kotlin) → `IosPriceViewModel`/`IosConverterState`
|
||||||
(`shared/src/appleMain/.../IosPriceViewModel.kt`, a flattened Map-free
|
(`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
|
locale-aware formatting via platform APIs, not translated text), and each
|
||||||
UI layer wraps it with its own localized "Updated %1$s" string.
|
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
|
## Locale-aware formatting
|
||||||
|
|
||||||
`domain/NumberFormat.kt` (digit grouping, decimal separator) and
|
`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
|
- `website/app/` is git-ignored — it's build output, generated fresh by CI
|
||||||
(and locally by the script below), never committed.
|
(and locally by the script below), never committed.
|
||||||
- To reproduce the production layout locally (site + web app together,
|
- To try the site + web app together locally (`/app/` links working):
|
||||||
`/app/` links working): `./website/serve-local.sh [port]`. It builds the
|
`./website/serve-local.sh [port]`. It builds `webHtmlApp`'s Compose
|
||||||
wasmJs distribution, copies it into `website/app/`, and serves
|
HTML (DOM) distribution, copies it into `website/app/`, and serves
|
||||||
`website/` with `python3 -m http.server`.
|
`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
|
- If you change the Pages workflow, remember `paths:` in the trigger
|
||||||
includes `webApp/**`/`shared/**`/Gradle files, not just `website/**` —
|
includes `webApp/**`/`shared/**`/`sharedUi/**`/Gradle files, not just
|
||||||
a shared-code change that affects the web app should also redeploy the
|
`website/**` — a shared-code change that affects the web app should
|
||||||
site.
|
also redeploy the site.
|
||||||
|
|
||||||
## Testing and verification
|
## 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`.
|
`--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
|
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.
|
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
|
- Interactive verification of the native macOS build via AppleScript/System
|
||||||
Events GUI scripting works but is genuinely flaky — stale processes can
|
Events GUI scripting works but is genuinely flaky — stale processes can
|
||||||
linger across launches, the accessibility tree doesn't always reflect
|
linger across launches, the accessibility tree doesn't always reflect
|
||||||
|
|||||||
@@ -71,13 +71,15 @@ options:
|
|||||||
- Desktop app:
|
- Desktop app:
|
||||||
- Hot reload: `./gradlew :desktopApp:hotRun --auto`
|
- Hot reload: `./gradlew :desktopApp:hotRun --auto`
|
||||||
- Standard run: `./gradlew :desktopApp:run`
|
- 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`
|
- Wasm target (faster, modern browsers): `./gradlew :webApp:wasmJsBrowserDevelopmentRun`
|
||||||
- JS target (slower, supports older browsers): `./gradlew :webApp:jsBrowserDevelopmentRun`
|
- 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.
|
- 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](./website) with the web app built and served at `/app/` (the `webHtmlApp` DOM build,
|
||||||
`./website/serve-local.sh` (serves at http://localhost:8000 by default; pass a port number to
|
not the one production actually deploys): `./website/serve-local.sh` (serves at
|
||||||
override)
|
http://localhost:8000 by default; pass a port number to override)
|
||||||
|
|
||||||
### Running tests
|
### Running tests
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ kotlin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":shared"))
|
implementation(project(":sharedUi"))
|
||||||
|
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":shared"))
|
implementation(project(":sharedUi"))
|
||||||
|
|
||||||
implementation(compose.desktop.currentOs)
|
implementation(compose.desktop.currentOs)
|
||||||
implementation(libs.kotlinx.coroutinesSwing)
|
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-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
|
||||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
|
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
|
||||||
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
|
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-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" }
|
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" }
|
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-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" }
|
||||||
compose-materialIconsExtended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "composeMaterialIcons" }
|
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-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" }
|
compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" }
|
||||||
moko-resources = { module = "dev.icerock.moko:resources", version.ref = "mokoResources" }
|
moko-resources = { module = "dev.icerock.moko:resources", version.ref = "mokoResources" }
|
||||||
moko-resourcesCompose = { module = "dev.icerock.moko:resources-compose", version.ref = "mokoResources" }
|
moko-resourcesCompose = { module = "dev.icerock.moko:resources-compose", version.ref = "mokoResources" }
|
||||||
|
|||||||
@@ -274,7 +274,6 @@ struct ContentView: View {
|
|||||||
unselectedCurrencies: state.unselectedCurrencies,
|
unselectedCurrencies: state.unselectedCurrencies,
|
||||||
pricedCurrencyCodes: state.pricedCurrencyCodes,
|
pricedCurrencyCodes: state.pricedCurrencyCodes,
|
||||||
sourceName: state.sourceName,
|
sourceName: state.sourceName,
|
||||||
localeCurrencyCode: state.localeCurrencyCode,
|
|
||||||
selectedCount: state.selectedCurrencyCodes.count,
|
selectedCount: state.selectedCurrencyCodes.count,
|
||||||
onToggle: { viewModel.onFiatCurrencyToggled($0) },
|
onToggle: { viewModel.onFiatCurrencyToggled($0) },
|
||||||
onReset: { viewModel.onSelectedCurrenciesReset() }
|
onReset: { viewModel.onSelectedCurrenciesReset() }
|
||||||
@@ -404,7 +403,7 @@ private struct NumericField: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func currencyFieldLabel(for code: String) -> String {
|
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 "\(flag) \(code)"
|
||||||
}
|
}
|
||||||
return code
|
return code
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ struct CurrencyPickerSheet: View {
|
|||||||
let unselectedCurrencies: [CurrencyInfo]
|
let unselectedCurrencies: [CurrencyInfo]
|
||||||
let pricedCurrencyCodes: [String]
|
let pricedCurrencyCodes: [String]
|
||||||
let sourceName: String
|
let sourceName: String
|
||||||
let localeCurrencyCode: String?
|
|
||||||
let selectedCount: Int
|
let selectedCount: Int
|
||||||
let onToggle: (String) -> Void
|
let onToggle: (String) -> Void
|
||||||
let onReset: () -> Void
|
let onReset: () -> Void
|
||||||
@@ -142,19 +141,11 @@ struct CurrencyPickerSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func currencyLabel(for info: CurrencyInfo) -> String {
|
private func currencyLabel(for info: CurrencyInfo) -> String {
|
||||||
let text: String
|
let text = IosLocalizationKt.localizedFormattedString(
|
||||||
if info.code == localeCurrencyCode {
|
resource: MR.strings.shared.currency_option_label,
|
||||||
text = IosLocalizationKt.localizedFormattedString(
|
args: [info.code, info.displayName]
|
||||||
resource: MR.strings.shared.currency_option_label_local,
|
)
|
||||||
args: [info.code, info.displayName]
|
if let flag = CurrencyFlagKt.currencyFlagEmoji(code: info.code, supportsFlagEmoji: SystemCurrencies_appleKt.supportsFlagEmoji()) {
|
||||||
)
|
|
||||||
} else {
|
|
||||||
text = IosLocalizationKt.localizedFormattedString(
|
|
||||||
resource: MR.strings.shared.currency_option_label,
|
|
||||||
args: [info.code, info.displayName]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if let flag = CurrencyFlagKt.currencyFlagEmoji(code: info.code) {
|
|
||||||
return "\(flag) \(text)"
|
return "\(flag) \(text)"
|
||||||
}
|
}
|
||||||
return text
|
return text
|
||||||
|
|||||||
@@ -7,11 +7,6 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273"
|
resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273"
|
||||||
integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg==
|
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":
|
"@messageformat/core@3.1.0":
|
||||||
version "3.1.0"
|
version "3.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/@messageformat/core/-/core-3.1.0.tgz#d4d2f5c3555228a6b5980b122a02b53dfc6458bd"
|
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"
|
resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273"
|
||||||
integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg==
|
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":
|
"@jsonjoy.com/base64@17.67.0":
|
||||||
version "17.67.0"
|
version "17.67.0"
|
||||||
resolved "https://registry.yarnpkg.com/@jsonjoy.com/base64/-/base64-17.67.0.tgz#7eeda3cb41138d77a90408fd2e42b2aba10576d7"
|
resolved "https://registry.yarnpkg.com/@jsonjoy.com/base64/-/base64-17.67.0.tgz#7eeda3cb41138d77a90408fd2e42b2aba10576d7"
|
||||||
|
|||||||
@@ -30,4 +30,6 @@ dependencyResolutionManagement {
|
|||||||
include(":androidApp")
|
include(":androidApp")
|
||||||
include(":desktopApp")
|
include(":desktopApp")
|
||||||
include(":shared")
|
include(":shared")
|
||||||
|
include(":sharedUi")
|
||||||
include(":webApp")
|
include(":webApp")
|
||||||
|
include(":webHtmlApp")
|
||||||
+6
-18
@@ -4,8 +4,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlinMultiplatform)
|
alias(libs.plugins.kotlinMultiplatform)
|
||||||
alias(libs.plugins.androidMultiplatformLibrary)
|
alias(libs.plugins.androidMultiplatformLibrary)
|
||||||
alias(libs.plugins.composeMultiplatform)
|
|
||||||
alias(libs.plugins.composeCompiler)
|
|
||||||
alias(libs.plugins.kotlinSerialization)
|
alias(libs.plugins.kotlinSerialization)
|
||||||
alias(libs.plugins.mokoResources)
|
alias(libs.plugins.mokoResources)
|
||||||
alias(libs.plugins.sqldelight)
|
alias(libs.plugins.sqldelight)
|
||||||
@@ -62,28 +60,22 @@ kotlin {
|
|||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
implementation(libs.compose.uiToolingPreview)
|
|
||||||
implementation(libs.compose.uiTooling)
|
|
||||||
implementation(libs.ktor.client.okhttp)
|
implementation(libs.ktor.client.okhttp)
|
||||||
implementation(libs.sqldelight.androidDriver)
|
implementation(libs.sqldelight.androidDriver)
|
||||||
}
|
}
|
||||||
commonMain.dependencies {
|
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.resources)
|
||||||
api(libs.moko.resourcesCompose)
|
// PriceViewModel's own supertype and CurrencyConverter's BigDecimal parameters are
|
||||||
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
// part of :shared's public API, so consumers that touch those types directly (e.g.
|
||||||
implementation(libs.androidx.lifecycle.runtimeCompose)
|
// 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.datetime)
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
implementation(libs.ktor.client.core)
|
implementation(libs.ktor.client.core)
|
||||||
implementation(libs.ktor.client.contentNegotiation)
|
implementation(libs.ktor.client.contentNegotiation)
|
||||||
implementation(libs.ktor.serialization.kotlinxJson)
|
implementation(libs.ktor.serialization.kotlinxJson)
|
||||||
implementation(libs.bignum)
|
|
||||||
implementation(libs.sqldelight.runtime)
|
implementation(libs.sqldelight.runtime)
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
@@ -112,10 +104,6 @@ kotlin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
|
||||||
androidRuntimeClasspath(libs.compose.uiTooling)
|
|
||||||
}
|
|
||||||
|
|
||||||
multiplatformResources {
|
multiplatformResources {
|
||||||
resourcesPackage.set("xyz.tyiu.satsprice.shared")
|
resourcesPackage.set("xyz.tyiu.satsprice.shared")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ data class IosConverterState(
|
|||||||
val unselectedCurrencies: List<CurrencyInfo>,
|
val unselectedCurrencies: List<CurrencyInfo>,
|
||||||
val selectedCurrencyCodes: List<String>,
|
val selectedCurrencyCodes: List<String>,
|
||||||
val pricedCurrencyCodes: List<String>,
|
val pricedCurrencyCodes: List<String>,
|
||||||
val localeCurrencyCode: String?,
|
|
||||||
val defaultCurrencyCode: String,
|
val defaultCurrencyCode: String,
|
||||||
val sourceName: String,
|
val sourceName: String,
|
||||||
val isManualSource: Boolean,
|
val isManualSource: Boolean,
|
||||||
@@ -81,7 +80,6 @@ private fun ConverterUiState.toIosState(): IosConverterState = IosConverterState
|
|||||||
unselectedCurrencies = unselectedCurrencies(),
|
unselectedCurrencies = unselectedCurrencies(),
|
||||||
selectedCurrencyCodes = selectedFiatCurrencies,
|
selectedCurrencyCodes = selectedFiatCurrencies,
|
||||||
pricedCurrencyCodes = pricedCurrencyCodes.toList(),
|
pricedCurrencyCodes = pricedCurrencyCodes.toList(),
|
||||||
localeCurrencyCode = localeCurrencyCode,
|
|
||||||
defaultCurrencyCode = defaultCurrencyCode,
|
defaultCurrencyCode = defaultCurrencyCode,
|
||||||
sourceName = sourceName,
|
sourceName = sourceName,
|
||||||
isManualSource = isManualSource,
|
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]
|
* 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
|
* 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
|
* (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
|
* can't render one anyway. [supportsFlagEmoji] defaults to the expect/actual of the same name,
|
||||||
* to fall back to, so a flag's regional-indicator codepoints draw as empty boxes there).
|
* 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? {
|
fun currencyFlagEmoji(code: String, supportsFlagEmoji: Boolean = supportsFlagEmoji()): String? {
|
||||||
if (!supportsFlagEmoji()) return null
|
if (!supportsFlagEmoji) return null
|
||||||
val regionCodes = issuingCountryCodes(code)
|
val regionCodes = issuingCountryCodes(code)
|
||||||
if (regionCodes.isEmpty() || regionCodes.size > MAX_FLAGS_PER_CURRENCY) return null
|
if (regionCodes.isEmpty() || regionCodes.size > MAX_FLAGS_PER_CURRENCY) return null
|
||||||
return regionCodes.joinToString(" ") { regionFlagEmoji(it) }
|
return regionCodes.joinToString(" ") { regionFlagEmoji(it) }
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
<string name="current_currency_section_title">Current Currency</string>
|
<string name="current_currency_section_title">Current Currency</string>
|
||||||
<string name="currency_not_priced">Not priced by %1$s</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">%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_options_content_description">Options for %1$s</string>
|
||||||
<string name="currency_to_sats">%1$s to Sats</string>
|
<string name="currency_to_sats">%1$s to Sats</string>
|
||||||
<string name="done">Done</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,
|
isSelected = true,
|
||||||
isPriced = state.isPriced(currentCurrency.code),
|
isPriced = state.isPriced(currentCurrency.code),
|
||||||
sourceName = state.sourceName,
|
sourceName = state.sourceName,
|
||||||
localeCurrencyCode = state.localeCurrencyCode,
|
|
||||||
onClick = null,
|
onClick = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -625,7 +624,6 @@ private fun CurrencyPickerScreen(
|
|||||||
isSelected = true,
|
isSelected = true,
|
||||||
isPriced = state.isPriced(info.code),
|
isPriced = state.isPriced(info.code),
|
||||||
sourceName = state.sourceName,
|
sourceName = state.sourceName,
|
||||||
localeCurrencyCode = state.localeCurrencyCode,
|
|
||||||
onClick = { onToggle(info.code) },
|
onClick = { onToggle(info.code) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -649,7 +647,6 @@ private fun CurrencyPickerScreen(
|
|||||||
isSelected = false,
|
isSelected = false,
|
||||||
isPriced = true,
|
isPriced = true,
|
||||||
sourceName = state.sourceName,
|
sourceName = state.sourceName,
|
||||||
localeCurrencyCode = state.localeCurrencyCode,
|
|
||||||
onClick = { onToggle(info.code) },
|
onClick = { onToggle(info.code) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -671,7 +668,6 @@ private fun CurrencyPickerScreen(
|
|||||||
isSelected = false,
|
isSelected = false,
|
||||||
isPriced = false,
|
isPriced = false,
|
||||||
sourceName = state.sourceName,
|
sourceName = state.sourceName,
|
||||||
localeCurrencyCode = state.localeCurrencyCode,
|
|
||||||
onClick = { onToggle(info.code) },
|
onClick = { onToggle(info.code) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -689,14 +685,9 @@ private fun CurrencyRow(
|
|||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
isPriced: Boolean,
|
isPriced: Boolean,
|
||||||
sourceName: String,
|
sourceName: String,
|
||||||
localeCurrencyCode: String?,
|
|
||||||
onClick: (() -> Unit)?,
|
onClick: (() -> Unit)?,
|
||||||
) {
|
) {
|
||||||
val text = if (info.code == localeCurrencyCode) {
|
val text = stringResource(MR.strings.currency_option_label, info.code, info.displayName)
|
||||||
stringResource(MR.strings.currency_option_label_local, info.code, info.displayName)
|
|
||||||
} else {
|
|
||||||
stringResource(MR.strings.currency_option_label, info.code, info.displayName)
|
|
||||||
}
|
|
||||||
val label = currencyFlagEmoji(info.code)?.let { flag -> "$flag $text" } ?: text
|
val label = currencyFlagEmoji(info.code)?.let { flag -> "$flag $text" } ?: text
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -21,11 +21,9 @@ kotlin {
|
|||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
implementation(project(":shared"))
|
implementation(project(":sharedUi"))
|
||||||
|
|
||||||
implementation(libs.compose.ui)
|
implementation(libs.compose.ui)
|
||||||
|
|
||||||
implementation(npm("@js-joda/timezone", "2.25.1"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,6 @@ package xyz.tyiu.satsprice
|
|||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
import androidx.compose.ui.window.ComposeViewport
|
import androidx.compose.ui.window.ComposeViewport
|
||||||
|
|
||||||
@OptIn(ExperimentalWasmJsInterop::class)
|
|
||||||
@JsModule("@js-joda/timezone")
|
|
||||||
external object JsJodaTimeZoneModule
|
|
||||||
|
|
||||||
private val jsJodaTz = JsJodaTimeZoneModule
|
|
||||||
|
|
||||||
@OptIn(ExperimentalComposeUiApi::class)
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
fun main() {
|
fun main() {
|
||||||
ComposeViewport {
|
ComposeViewport {
|
||||||
|
|||||||
@@ -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)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
|
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
echo "Building web app (wasmJs)..."
|
echo "Building web app (webHtmlApp)..."
|
||||||
"$REPO_ROOT/gradlew" -p "$REPO_ROOT" :webApp:wasmJsBrowserDistribution
|
"$REPO_ROOT/gradlew" -p "$REPO_ROOT" :webHtmlApp:jsBrowserDistribution
|
||||||
|
|
||||||
echo "Copying web app into website/app/..."
|
echo "Copying web app into website/app/..."
|
||||||
mkdir -p "$SCRIPT_DIR/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/)"
|
echo "Serving website at http://localhost:$PORT/ (web app at http://localhost:$PORT/app/)"
|
||||||
cd "$SCRIPT_DIR" && python3 -m http.server "$PORT"
|
cd "$SCRIPT_DIR" && python3 -m http.server "$PORT"
|
||||||
|
|||||||
Reference in New Issue
Block a user