mirror of
https://github.com/tiennm99/loto.git
synced 2026-10-13 14:16:10 +00:00
feat(android)!: replace the Capacitor wrapper with a native Kotlin/Compose app
Fresh Kotlin 2.2 + Jetpack Compose app at feature parity with web/ (game logic, settings contract, voice semantics ported one-to-one with twinned tests; 104 unit + 9 instrumentation tests). Fully offline: VIBRATE is the only permission; Media3's ACCESS_NETWORK_STATE is stripped. Voice clips mount from web/static/audio at build time. Same applicationId, signing env contract, and Play alpha track; versionCode 7 / 0.2.0. CI Android job is Gradle-only (lint, test, debug + R8 release APK) and the release workflow fails fast on an unbumped versionCode. BREAKING CHANGE: WebView localStorage (settings, in-progress round) is not migrated; wrapper users start clean.
This commit is contained in:
1 parent
8f9b03ca05
commit
e314ae01c8
126 files changed
+6390
-2275
No files matched your search
@@ -1,19 +1,12 @@
|
||||
name: Set up Android toolchain
|
||||
description: >
|
||||
Installs Node, JDK and Gradle, then restores android/ dependencies from the
|
||||
lockfile. Covers the Capacitor CLI only — the web bundle it syncs is built
|
||||
separately and must already exist at web/build.
|
||||
Installs the JDK and configures Gradle caching for the native Android
|
||||
project in android/. The app is pure Kotlin/Compose — no Node, no web
|
||||
build artifact.
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
# Keep in lockstep with .github/actions/setup-web.
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: android/package-lock.json
|
||||
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
@@ -22,7 +15,3 @@ runs:
|
||||
- uses: gradle/actions/setup-gradle@v6
|
||||
with:
|
||||
cache-provider: basic
|
||||
|
||||
- run: npm ci
|
||||
shell: bash
|
||||
working-directory: android
|
||||
@@ -23,23 +23,43 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Full history + tags for the versionCode guard below.
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: ./.github/actions/setup-web
|
||||
|
||||
# ci.yml does not run on tags, so this is the only gate before signing.
|
||||
- name: Test web
|
||||
run: npm test
|
||||
working-directory: web
|
||||
|
||||
# Base "" — the APK loads the bundle from the domain root.
|
||||
- name: Build web
|
||||
run: npm run build
|
||||
working-directory: web
|
||||
# Play rejects an upload whose versionCode is not higher than the last
|
||||
# one, but only after the whole build has run — fail fast instead.
|
||||
# Compares this tag's versionCode against the previous v*.*.* tag,
|
||||
# reading the wrapper-era path for tags that predate the native app.
|
||||
- name: Guard against an unbumped versionCode
|
||||
run: |
|
||||
set -eu
|
||||
current=$(grep -oP 'versionCode = \K\d+' android/app/build.gradle.kts)
|
||||
echo "versionCode at ${GITHUB_REF_NAME}: $current"
|
||||
prev_tag=$(git tag --list 'v*.*.*' --sort=-v:refname | grep -Fvx "${GITHUB_REF_NAME}" | head -n1 || true)
|
||||
if [ -z "$prev_tag" ]; then
|
||||
echo "No previous release tag; skipping comparison."
|
||||
exit 0
|
||||
fi
|
||||
prev=$(git show "$prev_tag:android/app/build.gradle.kts" 2>/dev/null | grep -oP 'versionCode = \K\d+' || true)
|
||||
if [ -z "$prev" ]; then
|
||||
# Wrapper-era layout (Capacitor project under android/android/).
|
||||
prev=$(git show "$prev_tag:android/android/app/build.gradle" 2>/dev/null | grep -oP 'versionCode \K\d+' || true)
|
||||
fi
|
||||
if [ -z "$prev" ]; then
|
||||
echo "::warning::Could not read versionCode at $prev_tag; skipping comparison."
|
||||
exit 0
|
||||
fi
|
||||
echo "versionCode at $prev_tag: $prev"
|
||||
if [ "$current" -le "$prev" ]; then
|
||||
echo "::error::versionCode $current at ${GITHUB_REF_NAME} must be greater than $prev at $prev_tag. Bump it in android/app/build.gradle.kts before tagging."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: ./.github/actions/setup-android
|
||||
|
||||
- name: Sync web bundle into the native project
|
||||
run: npm run sync
|
||||
# ci.yml does not run on tags, so this is the only gate before signing.
|
||||
- name: Lint and test
|
||||
run: ./gradlew :app:lint :app:test
|
||||
working-directory: android
|
||||
|
||||
- name: Decode keystore
|
||||
@@ -48,7 +68,7 @@ jobs:
|
||||
run: printf '%s' "$KEYSTORE_BASE64" | base64 --decode > "$GITHUB_WORKSPACE/keystore.p12"
|
||||
|
||||
- name: Build signed AAB + APK
|
||||
working-directory: android/android
|
||||
working-directory: android
|
||||
env:
|
||||
LOTO_KEYSTORE_PATH: ${{ github.workspace }}/keystore.p12
|
||||
LOTO_KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
@@ -60,8 +80,8 @@ jobs:
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
|
||||
with:
|
||||
files: |
|
||||
android/android/app/build/outputs/apk/release/*.apk
|
||||
android/android/app/build/outputs/bundle/release/*.aab
|
||||
android/app/build/outputs/apk/release/*.apk
|
||||
android/app/build/outputs/bundle/release/*.aab
|
||||
|
||||
# Auto-publish to Google Play (gated on the service-account secret).
|
||||
# Skips silently if PLAY_SERVICE_ACCOUNT_JSON is not configured, so
|
||||
@@ -89,8 +109,8 @@ jobs:
|
||||
with:
|
||||
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
packageName: com.miti99.loto
|
||||
releaseFiles: android/android/app/build/outputs/bundle/release/*.aab
|
||||
releaseFiles: android/app/build/outputs/bundle/release/*.aab
|
||||
tracks: alpha
|
||||
status: completed
|
||||
# Bumps versionCode automatically? No — must be incremented
|
||||
# manually in android/android/app/build.gradle before tagging.
|
||||
# versionCode is bumped manually in android/app/build.gradle.kts
|
||||
# before tagging; the guard step above catches a forgotten bump.
|
||||
@@ -3,9 +3,11 @@
|
||||
# The web app is built twice and only twice — once per base path — and every
|
||||
# consumer downloads an artifact instead of rebuilding:
|
||||
#
|
||||
# build (root, base "") -> deploy-firebase, preview-firebase, android-debug
|
||||
# build (root, base "") -> deploy-firebase, preview-firebase
|
||||
# build (gh, base /loto) -> deploy-pages
|
||||
#
|
||||
# The Android app is native Kotlin/Compose and builds independently of the
|
||||
# web bundle (it mounts web/static/audio straight from the checkout).
|
||||
# Tags are handled by android-release.yml, which builds standalone.
|
||||
|
||||
name: ci
|
||||
@@ -154,28 +156,20 @@ jobs:
|
||||
entryPoint: web
|
||||
|
||||
android-debug:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: web-build
|
||||
# capacitor.config.json declares `webDir: "../web/build"`.
|
||||
path: web/build
|
||||
- uses: ./.github/actions/setup-android
|
||||
# `npm run build` would rebuild web/; the bundle is already here.
|
||||
- name: Sync web bundle into the native project
|
||||
run: npm run sync
|
||||
# assembleRelease runs R8 with no secrets needed (unsigned without
|
||||
# LOTO_KEYSTORE_PATH) so a missing keep rule fails CI, not the tag.
|
||||
- name: Lint, test and assemble debug + release APKs
|
||||
run: ./gradlew :app:lint :app:test :app:assembleDebug :app:assembleRelease
|
||||
working-directory: android
|
||||
- name: Assemble debug APK
|
||||
run: ./gradlew :app:assembleDebug
|
||||
working-directory: android/android
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: app-debug.apk
|
||||
path: android/android/app/build/outputs/apk/debug/*.apk
|
||||
path: android/app/build/outputs/apk/debug/*.apk
|
||||
archive: false
|
||||
retention-days: 7
|
||||
@@ -1,7 +1,7 @@
|
||||
# Lô Tô
|
||||
|
||||
*Lô tô hội chợ* — Vietnamese fairground bingo. One number caller, two shipping
|
||||
targets: a static web app and a fully-offline Android APK that wraps it.
|
||||
targets: a static web app and a fully-offline native Android app.
|
||||
|
||||
Live: [tiennm99.github.io/loto](https://tiennm99.github.io/loto)
|
||||
|
||||
@@ -10,11 +10,12 @@ Live: [tiennm99.github.io/loto](https://tiennm99.github.io/loto)
|
||||
| Path | What it is |
|
||||
|------|------------|
|
||||
| [`web/`](web) | SvelteKit static app — the game itself. npm, Vitest, deployed to GitHub Pages and Firebase Hosting. |
|
||||
| [`android/`](android) | Capacitor wrapper that bundles `web/build` into an APK. npm + Gradle; the native project sits in `android/android/`. |
|
||||
| [`android/`](android) | Native Kotlin + Jetpack Compose app at feature parity with `web/`. Pure Gradle — no Node. |
|
||||
|
||||
`android/` builds `web/` as part of its own build, so a change under `web/`
|
||||
reaches the APK on the next `npm run build` in `android/`. There is no version
|
||||
pin between them — they ship from the same commit.
|
||||
The two apps share the voice clips: the Android build mounts
|
||||
`web/static/audio/` straight into the APK's assets, so the MP3s exist once in
|
||||
the repo. Game logic is hand-ported — a behavior change in `web/src/lib/`
|
||||
needs a matching Kotlin change (the web app is the behavioral spec).
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -22,8 +23,8 @@ pin between them — they ship from the same commit.
|
||||
# web
|
||||
cd web && npm install && npm run dev
|
||||
|
||||
# android debug APK (builds web/ first)
|
||||
cd android && npm ci && npm run build && npm run assemble:debug
|
||||
# android debug APK (requires JDK 21 + Android SDK)
|
||||
cd android && ./gradlew :app:assembleDebug
|
||||
```
|
||||
|
||||
Per-project detail lives in [`web/README.md`](web/README.md) and
|
||||
@@ -38,26 +39,27 @@ only the tag-driven release stands apart.
|
||||
| Workflow | Trigger | Result |
|
||||
|----------|---------|--------|
|
||||
| `ci` | push to `main` or PR touching `web/` or `android/` | see the job graph below |
|
||||
| `android-release` | tag `v*.*.*` | tests, then a signed AAB + APK on the GH Release, plus Play Store internal-track upload |
|
||||
| `android-release` | tag `v*.*.*` | versionCode guard, lint + tests, then a signed AAB + APK on the GH Release, plus Play Store closed-testing upload |
|
||||
|
||||
`ci` builds the web app exactly twice — once per base path — and every
|
||||
consumer downloads the artifact rather than rebuilding it:
|
||||
`ci` builds the web app exactly twice — once per base path — and the Android
|
||||
job builds independently from the same checkout:
|
||||
|
||||
```
|
||||
test (npm test)
|
||||
└── build (root, base "") ── deploy-firebase push to main
|
||||
│ ├─ preview-firebase PR from this repo
|
||||
│ └─ android-debug unsigned APK artifact
|
||||
│ └─ preview-firebase PR from this repo
|
||||
└── build (gh, base /loto) ── deploy-pages push to main
|
||||
|
||||
android-debug (gradle lint + test + assembleDebug) ── unsigned APK artifact
|
||||
```
|
||||
|
||||
Nothing deploys unless `test` is green. Deploy jobs are skipped on pull
|
||||
requests, and the Firebase preview is skipped for PRs from forks, which
|
||||
cannot read the service-account secret.
|
||||
|
||||
Shared toolchain setup lives in `.github/actions/setup-web` and
|
||||
`.github/actions/setup-android` — composite actions used by both workflows,
|
||||
so Node stays on one version across the web build and the APK.
|
||||
Shared toolchain setup lives in `.github/actions/setup-web` (Node) and
|
||||
`.github/actions/setup-android` (JDK 21 + Gradle caching) — composite actions
|
||||
used by both workflows.
|
||||
|
||||
Release/secret setup for the Play Store pipeline is documented in
|
||||
[`docs/play-store-publishing.md`](docs/play-store-publishing.md).
|
||||
@@ -70,6 +72,11 @@ for commit and interleaved in commit-time order under a single root, so
|
||||
`git log` reads as one timeline and `git log -- web/` or `git log -- android/`
|
||||
each resolve on the current paths.
|
||||
|
||||
`android/` has lived three lives: a native Kotlin/Compose port (retired
|
||||
2026-05-10), a Capacitor WebView wrapper around the web app, and — since
|
||||
2026-08-31 — a fresh native Kotlin/Compose rewrite that replaced the wrapper.
|
||||
All three are preserved in history.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0 — see [`LICENSE`](LICENSE).
|
||||
+6
-30
@@ -1,33 +1,9 @@
|
||||
# Node
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
|
||||
# Android build outputs
|
||||
android/.gradle/
|
||||
android/build/
|
||||
android/app/build/
|
||||
android/capacitor-cordova-android-plugins/
|
||||
android/local.properties
|
||||
android/.idea/
|
||||
android/captures/
|
||||
android/.externalNativeBuild/
|
||||
android/.cxx/
|
||||
|
||||
# Capacitor sync artifacts (regenerated by `npx cap sync`)
|
||||
android/app/src/main/assets/public/
|
||||
android/app/src/main/assets/capacitor.config.json
|
||||
android/app/src/main/assets/capacitor.plugins.json
|
||||
|
||||
# Signing
|
||||
*.apk
|
||||
*.aab
|
||||
.gradle/
|
||||
build/
|
||||
local.properties
|
||||
.idea/
|
||||
.kotlin/
|
||||
captures/
|
||||
*.keystore
|
||||
*.jks
|
||||
*.p12
|
||||
service-account*.json
|
||||
google-services.json
|
||||
|
||||
# Editor / OS
|
||||
*.iml
|
||||
.idea/
|
||||
.DS_Store
|
||||
+62
-232
@@ -1,258 +1,88 @@
|
||||
# Lô tô — Android (Capacitor wrapper)
|
||||
# Lô tô — Android
|
||||
|
||||

|
||||
Native Kotlin + Jetpack Compose app for *lô tô hội chợ*, at feature parity
|
||||
with the [`web/`](../web) SvelteKit app. Fully offline: the APK declares no
|
||||
`INTERNET` permission and bundles everything it needs, including the
|
||||
Vietnamese voice clips.
|
||||
|
||||
Fully-offline Android wrapper around the [`web/`](../web) SvelteKit PWA. All
|
||||
assets — HTML, JS, CSS, and 184 voice MP3s — are bundled into the APK at build
|
||||
time. No network is required at runtime.
|
||||
|
||||
> **Note:** the previous native Kotlin/Compose port lives in git history at
|
||||
> the commit titled `docs: add post-implementation todo list` and earlier. This
|
||||
> is now a thin wrapper over `web/`; the web app evolves and we rebuild + ship.
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
../web/
|
||||
└── npm run build → ../web/build/ (SvelteKit static output)
|
||||
↓
|
||||
npx cap sync
|
||||
↓
|
||||
android/app/src/main/assets/public/ (bundled into APK)
|
||||
↓
|
||||
WebView serves https://localhost/* off-disk
|
||||
```
|
||||
|
||||
Capacitor's bridge serves the bundled site from `https://localhost`, which is
|
||||
loopback only (no INTERNET permission requested). Workbox precache, IndexedDB,
|
||||
and the `<audio>` element all work offline.
|
||||
The web app is the behavioral spec — game rules, settings contract, and voice
|
||||
semantics are ported one-to-one from `web/src/lib/` and its test suites. A
|
||||
behavior change on the web side needs a matching Kotlin change here.
|
||||
|
||||
## Stack
|
||||
|
||||
- Capacitor 8 (Android wrapper)
|
||||
- Web app in `web/`: SvelteKit 2 + Vite 8 + `@sveltejs/adapter-static` + `@vite-pwa/sveltekit`
|
||||
- minSdk 24 · targetSdk 36 · JDK 21 · Node 24 (Capacitor 8 requires the first two)
|
||||
- Kotlin 2.x, Jetpack Compose, Material 3, single `:app` module
|
||||
- Gradle Kotlin DSL + version catalog (`gradle/libs.versions.toml`), JDK 21
|
||||
- `applicationId com.miti99.loto`, minSdk 24, targetSdk 36
|
||||
- Manual DI: `LotoApplication` owns the singletons; ViewModels come from
|
||||
`LotoViewModelFactory`. No Hilt.
|
||||
- Media3 ExoPlayer for voice clips, DataStore Preferences for settings and
|
||||
round state
|
||||
|
||||
## Setup
|
||||
## Layout
|
||||
|
||||
```bash
|
||||
git clone https://github.com/tiennm99/loto.git
|
||||
cd loto/android
|
||||
npm ci
|
||||
npm run build # builds web/ + cap sync into android/
|
||||
```
|
||||
| Package | What it is |
|
||||
|---------|------------|
|
||||
| `game` | Pure game rules ported from `web/src/lib/game-logic.js` and friends: card generator, row state, draw deck, auto-cross |
|
||||
| `audio` | Voice catalog (parses `assets/audio/manifest.json`) + ExoPlayer wrapper with the web's cancellation semantics |
|
||||
| `settings` | `Settings` model + DataStore repository — same fields, defaults, and per-field validation as the web store |
|
||||
| `state` | Round persistence, the shared master store, and the three ViewModels |
|
||||
| `ui` | Compose screens: player board, master panel, settings sheet, theme |
|
||||
|
||||
## Build
|
||||
## Audio assets
|
||||
|
||||
### Debug APK
|
||||
The 184 MP3s (2 voices × 92 clips) and their manifest are **not copied** into
|
||||
this project. `app/build.gradle.kts` mounts `../web/static/audio/` as
|
||||
`assets/audio/` at build time, so web and Android always ship the same files.
|
||||
Regenerate them with the web app's `scripts/generate-audio.py`.
|
||||
|
||||
```bash
|
||||
npm run build # build web/ + cap sync (must run after any web/ change)
|
||||
npm run assemble:debug # → android/app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
## Audio focus
|
||||
|
||||
### Release AAB + APK (signed)
|
||||
|
||||
```bash
|
||||
export LOTO_KEYSTORE_PATH=$HOME/.android/miti99-apps.p12
|
||||
export LOTO_KEYSTORE_PASSWORD=<store-password>
|
||||
export LOTO_KEY_ALIAS=<key-alias>
|
||||
export LOTO_KEY_PASSWORD=<key-password>
|
||||
|
||||
npm run build
|
||||
npm run assemble:release
|
||||
# → android/app/build/outputs/{apk/release/*.apk, bundle/release/*.aab}
|
||||
```
|
||||
|
||||
### Open in Android Studio
|
||||
|
||||
```bash
|
||||
npx cap open android
|
||||
```
|
||||
|
||||
## Picking up web/ changes
|
||||
|
||||
`web/` and `android/` live in the same repository, so there is no pin to bump —
|
||||
rebuild and re-sync after any change under `web/`:
|
||||
|
||||
```bash
|
||||
npm run build # rebuild web/ + re-sync into android/
|
||||
```
|
||||
|
||||
## Why no INTERNET permission?
|
||||
|
||||
The whole web build (HTML, JS, CSS, fonts, manifest, icons, all 184 MP3s)
|
||||
ships inside the APK. The WebView loads from `https://localhost`, which is
|
||||
loopback. No remote fetches happen at runtime, so the permission is omitted —
|
||||
this makes "fully offline" a hard guarantee, not a convention.
|
||||
|
||||
If you ever add a remote feature (analytics, sync, etc.), add this back to
|
||||
`android/app/src/main/AndroidManifest.xml`:
|
||||
|
||||
```xml
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
```
|
||||
The player requests **no audio focus**: clips are ~1s speech cues layered
|
||||
over whatever the table is already listening to, so music keeps playing and
|
||||
incoming calls behave normally. Playback still pauses when audio would become
|
||||
noisy (headphones unplugged). See `ExoVoicePlayer`.
|
||||
|
||||
## Permissions
|
||||
|
||||
| Permission | Why |
|
||||
|------------|-----|
|
||||
| `VIBRATE` | Haptic feedback when a player taps a cell. A WebView app must declare this itself for `navigator.vibrate()` to work — in a browser, Chrome holds the permission on the page's behalf, which is why the web build needs no equivalent. Normal permission: no runtime prompt, no Play data-safety impact. |
|
||||
`VIBRATE` only (cell-tap haptics; normal permission, no runtime prompt).
|
||||
Media3 would merge `ACCESS_NETWORK_STATE` in; the manifest strips it with
|
||||
`tools:node="remove"` to keep the hard offline guarantee.
|
||||
|
||||
No `INTERNET` — see above.
|
||||
## Build
|
||||
|
||||
## Android-specific behaviour
|
||||
|
||||
These exist because the wrapper differs from a browser tab. All three are
|
||||
invisible when testing the web app on a desktop.
|
||||
|
||||
**Screen stays awake during a round.** Auto-call advances on a timer with no
|
||||
touch input, so a full round can run 15 minutes untouched — long enough for the
|
||||
display to sleep and the WebView to throttle its interval. `MasterPanel` holds a
|
||||
Screen Wake Lock while a round is live (`web/src/lib/wake-lock.js`). Android
|
||||
drops the lock whenever the page hides, so the module re-acquires on
|
||||
`visibilitychange`. Player-only mode never takes a lock; those screens stay
|
||||
awake from the user's own taps.
|
||||
|
||||
**Back closes overlays, then confirms exit.** Android 16 (targetSdk 36) no
|
||||
longer calls `onBackPressed()` nor dispatches `KEYCODE_BACK`, so
|
||||
`MainActivity` registers an `OnBackPressedCallback` instead. Each open overlay
|
||||
pushes one history entry (`web/src/lib/overlay-history.js`), so "the WebView can
|
||||
go back" means exactly "an overlay is open": back closes the bingo modal or the
|
||||
settings sheet, and only at the root does it ask before quitting. Browsers get
|
||||
the same overlay behaviour for free.
|
||||
|
||||
**Board text size is app-controlled.** The player card is a fixed 9-column grid
|
||||
that clips at large system font scales, so `MainActivity` pins the WebView's
|
||||
`textZoom` to 100. Taking the system control away obliges replacing it: Settings
|
||||
→ **Cỡ chữ bảng** scales the board numbers instead. The two ship together — if
|
||||
one is ever removed, remove both.
|
||||
|
||||
## Running on BlueStacks / NoxPlayer / Android emulators
|
||||
|
||||
The APK has no native libraries (`lib/` is empty), so it's architecture-
|
||||
independent — same APK installs on x86_64 emulators and ARM phones.
|
||||
|
||||
1. Download the APK from the [Actions artifact](https://github.com/tiennm99/loto/actions) (debug)
|
||||
or [Releases](https://github.com/tiennm99/loto/releases) (signed).
|
||||
2. Drag-drop the APK onto the BlueStacks window, or use **Install APK**
|
||||
from the sidebar.
|
||||
3. Launch "Lo To" from the BlueStacks home screen.
|
||||
|
||||
If the app shows a blank white screen on first launch, open chrome://inspect
|
||||
on the host machine while BlueStacks is running, click **Inspect** on the
|
||||
WebView, and check the console — the WebView debugging is enabled in debug
|
||||
builds (Capacitor default behavior, no INTERNET permission needed because
|
||||
chrome://inspect uses ADB).
|
||||
|
||||
Manifest declares `touchscreen`, `faketouch`, `screen.portrait`, and
|
||||
`screen.landscape` as **optional** so the Play Store and emulators don't
|
||||
filter the app out.
|
||||
|
||||
## CI / CD
|
||||
|
||||
Workflows live at the repository root in `.github/workflows/`.
|
||||
|
||||
| Workflow | Trigger | Result |
|
||||
|----------|---------|--------|
|
||||
| `ci` (`android-debug` job) | push to `main`, any PR touching `web/` or `android/` | unsigned debug APK uploaded as artifact |
|
||||
| `android-release` | tag `v*.*.*` | tests, then signed AAB + APK attached to GH Release |
|
||||
|
||||
Both `cap sync` a web bundle into `android/android/`, then run Gradle there.
|
||||
They differ in where the bundle comes from: the `android-debug` job downloads
|
||||
the artifact `ci` already built, so `web/` is never built twice in one run,
|
||||
while `android-release` builds `web/` itself from the tagged tree. Neither
|
||||
runs `npm run build` in `android/` — that script would rebuild `web/`.
|
||||
|
||||
Toolchain setup is shared through the composite actions
|
||||
`.github/actions/setup-web` and `.github/actions/setup-android`.
|
||||
|
||||
### GitHub Secrets (release only)
|
||||
|
||||
| Secret | Required for | Description |
|
||||
|--------|--------------|-------------|
|
||||
| `KEYSTORE_BASE64` | signed build | `base64 -w0 miti99-apps.p12` |
|
||||
| `KEYSTORE_PASSWORD` | signed build | Keystore password |
|
||||
| `KEY_ALIAS` | signed build | Key alias |
|
||||
| `KEY_PASSWORD` | signed build | Key password |
|
||||
| `PLAY_SERVICE_ACCOUNT_JSON` | Play Store auto-publish (optional) | Full JSON content of Google Cloud service account key |
|
||||
|
||||
**Never commit `*.jks`, `*.keystore`, `*.p12`, service-account JSON, or `.env`.**
|
||||
|
||||
## Google Play Store
|
||||
|
||||
### One-time manual setup (cannot be automated)
|
||||
|
||||
1. Sign up at [play.google.com/console](https://play.google.com/console/signup) ($25 one-time)
|
||||
2. Create the app entry with package name `com.miti99.loto`
|
||||
3. Build a signed AAB (`npm run assemble:release` locally, or push a `v*.*.*` tag to use `android-release.yml`) and **upload manually** to the Internal Testing track via the Play Console UI — Google requires the first upload to be manual
|
||||
4. Fill out store listing: icon (512×512), feature graphic (1024×500), 2–8 screenshots, short + full description, category, content rating, target audience, **privacy policy URL** (host on GH Pages), data safety form (declare "No data collected" since the app is offline)
|
||||
5. Submit for review (1–7 days first time)
|
||||
|
||||
### Auto-publish setup (after first manual upload)
|
||||
|
||||
See [`docs/play-store-publishing.md`](../docs/play-store-publishing.md) for the
|
||||
full walkthrough: service-account creation, granting Play Console permissions,
|
||||
setting the GitHub secrets (bash + PowerShell commands), cutting a release,
|
||||
and troubleshooting. Short version: once `PLAY_SERVICE_ACCOUNT_JSON` is set,
|
||||
every `v*.*.*` tag builds a signed AAB + APK, attaches both to a GitHub
|
||||
Release, and uploads the AAB to the Play Console **closed testing track**
|
||||
(`alpha`), which is the one whose testers count toward production access.
|
||||
Promote closed → open → production via the Play Console UI (or change
|
||||
`tracks:` in `android-release.yml` to automate further).
|
||||
|
||||
**Important:** every release must increment `versionCode` in `android/app/build.gradle` before tagging — Play Console rejects duplicate versionCodes.
|
||||
|
||||
Tag a release:
|
||||
Requires JDK 21 and an Android SDK (compileSdk 36).
|
||||
|
||||
```bash
|
||||
git tag v1.0.0
|
||||
git push origin v1.0.0
|
||||
./gradlew :app:lint :app:test :app:assembleDebug # checks + debug APK
|
||||
./gradlew :app:bundleRelease # release AAB
|
||||
```
|
||||
|
||||
## Version bump
|
||||
Debug builds need no signing setup. Release builds sign with a PKCS#12
|
||||
keystore read from env vars — the same contract every previous release used:
|
||||
|
||||
1. Edit `versionCode` and `versionName` in `android/app/build.gradle`.
|
||||
2. Commit, tag, push.
|
||||
| Env var | Meaning |
|
||||
|---------|---------|
|
||||
| `LOTO_KEYSTORE_PATH` | absolute path to the `.p12` keystore |
|
||||
| `LOTO_KEYSTORE_PASSWORD` | keystore password |
|
||||
| `LOTO_KEY_ALIAS` | key alias |
|
||||
| `LOTO_KEY_PASSWORD` | key password |
|
||||
|
||||
## App ID
|
||||
Without `LOTO_KEYSTORE_PATH`, release artifacts build unsigned.
|
||||
|
||||
`com.miti99.loto` — set in `capacitor.config.json` and `android/app/build.gradle`.
|
||||
## Release
|
||||
|
||||
Releases are tag-driven: bump `versionCode` + `versionName` in
|
||||
`app/build.gradle.kts`, tag `v*.*.*`, push. `.github/workflows/android-release.yml`
|
||||
guards against a forgotten versionCode bump, runs lint + tests, builds the
|
||||
signed AAB + APK, attaches them to the GitHub Release, and uploads the AAB to
|
||||
the Play closed-testing track (`alpha`). Secrets and Play setup are documented
|
||||
in [`docs/play-store-publishing.md`](../docs/play-store-publishing.md).
|
||||
|
||||
## Icons and splash
|
||||
|
||||
Launcher art is generated from [`web/static/icons/source.svg`](../web/static/icons/source.svg)
|
||||
— the same brand mark the web app and PWA use — not hand-maintained per
|
||||
density. Resources:
|
||||
|
||||
| Resource | What |
|
||||
|----------|------|
|
||||
| `mipmap-*/ic_launcher.png`, `ic_launcher_round.png` | Legacy icons, API ≤ 25 |
|
||||
| `mipmap-*/ic_launcher_foreground.png` | Adaptive foreground; text sized to fit the 66% safe circle |
|
||||
| `mipmap-*/ic_launcher_monochrome.png` | Android 13+ themed icons |
|
||||
| `drawable/ic_launcher_background.xml` | Adaptive background, brand gradient as a vector |
|
||||
| `drawable/splash.xml`, `drawable-night/splash.xml` | Splash, API < 31 |
|
||||
| `values*/colors.xml` → `splash_background` | Splash colour, light + dark |
|
||||
|
||||
On API 31+ the splash comes from `windowSplashScreenBackground` /
|
||||
`windowSplashScreenAnimatedIcon` in `AppTheme.NoActionBarLaunch` instead of the
|
||||
drawable.
|
||||
|
||||
Regenerating requires Roboto Condensed on the rendering host; the font ships as
|
||||
`.woff` in `web/node_modules/@fontsource/roboto-condensed` and has to be
|
||||
converted to `.ttf` for `rsvg-convert` to see it.
|
||||
|
||||
> **Note:** `source.svg` sets `font-size="240"`, which overflows the 512px
|
||||
> canvas — the PNGs under `web/static/icons/` are visibly clipped on both
|
||||
> sides. The Android art is rendered at a corrected size. The web PWA icons
|
||||
> still carry the original bug.
|
||||
|
||||
## Audio
|
||||
|
||||
Bundled by the web app under `web/static/audio/{hoai-my,nam-minh}/{1..90,cho,kinh}.mp3`,
|
||||
served by the wrapper from `https://localhost/audio/...`. No audio post-processing
|
||||
on the Android side.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0 — see [`LICENSE`](../LICENSE) at the repository root.
|
||||
Launcher art (adaptive foreground/background, monochrome for Android 13+
|
||||
themed icons, legacy mipmaps) was rendered from `web/static/icons/source.svg`
|
||||
and is committed under `app/src/main/res/mipmap-*`. The splash uses
|
||||
`androidx.core:core-splashscreen` on API 31+ and a layer-list drawable below,
|
||||
with light/dark backgrounds matching the web (`#FFFBEB` / `#050813`).
|
||||
@@ -1,101 +0,0 @@
|
||||
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||
|
||||
# Built application files
|
||||
*.apk
|
||||
*.aar
|
||||
*.ap_
|
||||
*.aab
|
||||
|
||||
# Files for the ART/Dalvik VM
|
||||
*.dex
|
||||
|
||||
# Java class files
|
||||
*.class
|
||||
|
||||
# Generated files
|
||||
bin/
|
||||
gen/
|
||||
out/
|
||||
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||
# release/
|
||||
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
# Proguard folder generated by Eclipse
|
||||
proguard/
|
||||
|
||||
# Log Files
|
||||
*.log
|
||||
|
||||
# Android Studio Navigation editor temp files
|
||||
.navigation/
|
||||
|
||||
# Android Studio captures folder
|
||||
captures/
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/workspace.xml
|
||||
.idea/tasks.xml
|
||||
.idea/gradle.xml
|
||||
.idea/assetWizardSettings.xml
|
||||
.idea/dictionaries
|
||||
.idea/libraries
|
||||
# Android Studio 3 in .gitignore file.
|
||||
.idea/caches
|
||||
.idea/modules.xml
|
||||
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||
.idea/navEditor.xml
|
||||
|
||||
# Keystore files
|
||||
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||
#*.jks
|
||||
#*.keystore
|
||||
|
||||
# External native build folder generated in Android Studio 2.2 and later
|
||||
.externalNativeBuild
|
||||
.cxx/
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
# google-services.json
|
||||
|
||||
# Freeline
|
||||
freeline.py
|
||||
freeline/
|
||||
freeline_project_description.json
|
||||
|
||||
# fastlane
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots
|
||||
fastlane/test_output
|
||||
fastlane/readme.md
|
||||
|
||||
# Version control
|
||||
vcs.xml
|
||||
|
||||
# lint
|
||||
lint/intermediates/
|
||||
lint/generated/
|
||||
lint/outputs/
|
||||
lint/tmp/
|
||||
# lint/reports/
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-android-plugins
|
||||
|
||||
# Copied web assets
|
||||
app/src/main/assets/public
|
||||
|
||||
# Generated Config files
|
||||
app/src/main/assets/capacitor.config.json
|
||||
app/src/main/assets/capacitor.plugins.json
|
||||
app/src/main/res/xml/config.xml
|
||||
@@ -1,2 +0,0 @@
|
||||
/build/*
|
||||
!/build/.npmkeep
|
||||
@@ -1,72 +0,0 @@
|
||||
apply plugin: 'com.android.application'
|
||||
|
||||
android {
|
||||
namespace = "com.miti99.loto"
|
||||
compileSdk = rootProject.ext.compileSdkVersion
|
||||
defaultConfig {
|
||||
applicationId "com.miti99.loto"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 6
|
||||
versionName "0.1.2"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
}
|
||||
signingConfigs {
|
||||
// Reads from env vars so the keystore never lands in the repo.
|
||||
// Mirrors the original loto-android signing convention.
|
||||
release {
|
||||
def keystorePath = System.getenv('LOTO_KEYSTORE_PATH')
|
||||
if (keystorePath) {
|
||||
storeFile file(keystorePath)
|
||||
storeType "pkcs12"
|
||||
storePassword System.getenv('LOTO_KEYSTORE_PASSWORD')
|
||||
keyAlias System.getenv('LOTO_KEY_ALIAS')
|
||||
keyPassword System.getenv('LOTO_KEY_PASSWORD')
|
||||
}
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
// Only sign when env vars are present; debug builds remain debug-signed.
|
||||
if (System.getenv('LOTO_KEYSTORE_PATH')) {
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
flatDir{
|
||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation project(':capacitor-android')
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
}
|
||||
|
||||
apply from: 'capacitor.build.gradle'
|
||||
|
||||
try {
|
||||
def servicesJSON = file('google-services.json')
|
||||
if (servicesJSON.text) {
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
}
|
||||
} catch(Exception e) {
|
||||
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (hasProperty('postBuildExtras')) {
|
||||
postBuildExtras()
|
||||
}
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class ExampleInstrumentedTest {
|
||||
|
||||
@Test
|
||||
public void useAppContext() throws Exception {
|
||||
// Context of the app under test.
|
||||
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
|
||||
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/title_activity_main"
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"></meta-data>
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
<!-- No INTERNET permission: app is fully offline. All assets bundled in
|
||||
APK; WebView serves from https://localhost which uses loopback. -->
|
||||
|
||||
<!-- Haptic feedback on cell taps. A WebView app must declare this itself
|
||||
for navigator.vibrate() to work — Chrome holds the permission on the
|
||||
browser's behalf, which is why the web build needs no equivalent.
|
||||
Normal permission: no runtime prompt, no data-safety impact. -->
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<!-- Mark hardware as optional so emulators (BlueStacks, NoxPlayer, AVDs)
|
||||
don't get filtered out. BlueStacks reports `faketouch` instead of
|
||||
`touchscreen` and lacks real telephony/GPS hardware. -->
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.faketouch" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.screen.portrait" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.screen.landscape" android:required="false" />
|
||||
</manifest>
|
||||
@@ -1,74 +0,0 @@
|
||||
package com.miti99.loto;
|
||||
|
||||
import android.media.AudioManager;
|
||||
import android.os.Bundle;
|
||||
import android.webkit.WebView;
|
||||
import androidx.activity.OnBackPressedCallback;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import com.getcapacitor.Bridge;
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
WebView webView = webView();
|
||||
if (webView != null) {
|
||||
// The player card is a fixed 9-column grid, so the system font
|
||||
// scale (users routinely run 130-200%) clips two-digit numbers
|
||||
// out of their cells. Pinning the zoom is only defensible
|
||||
// because the app ships its own size control in Settings
|
||||
// ("Cỡ chữ bảng") as the accessible replacement.
|
||||
webView.getSettings().setTextZoom(100);
|
||||
}
|
||||
|
||||
// Calling numbers aloud is the app's whole job, so the volume rocker
|
||||
// should reach the media stream even before the first clip plays.
|
||||
setVolumeControlStream(AudioManager.STREAM_MUSIC);
|
||||
|
||||
// Android 16 (targetSdk 36) no longer calls onBackPressed() and no
|
||||
// longer dispatches KEYCODE_BACK, so back has to come through the
|
||||
// dispatcher. Each open overlay pushes one history entry (see
|
||||
// web/src/lib/overlay-history.js), which makes "the WebView can go
|
||||
// back" mean exactly "an overlay is open".
|
||||
getOnBackPressedDispatcher()
|
||||
.addCallback(
|
||||
this,
|
||||
new OnBackPressedCallback(true) {
|
||||
@Override
|
||||
public void handleOnBackPressed() {
|
||||
WebView wv = webView();
|
||||
if (wv != null && wv.canGoBack()) {
|
||||
wv.goBack();
|
||||
return;
|
||||
}
|
||||
confirmExit();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** Null-safe accessor — the bridge is built during super.onCreate(). */
|
||||
private WebView webView() {
|
||||
Bridge bridge = getBridge();
|
||||
return bridge == null ? null : bridge.getWebView();
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-call runs untouched for minutes at a time, so a stray back press
|
||||
* at the root would otherwise drop the caller out of a live round.
|
||||
*/
|
||||
private void confirmExit() {
|
||||
// Explicit AppCompat dialog theme: the activity runs on
|
||||
// AppTheme.NoActionBarLaunch (parent Theme.SplashScreen), which is
|
||||
// not guaranteed to be AppCompat-derived at dialog time.
|
||||
new AlertDialog.Builder(this, R.style.AppTheme_ExitDialog)
|
||||
.setTitle(R.string.exit_title)
|
||||
.setMessage(R.string.exit_message)
|
||||
.setNegativeButton(R.string.exit_cancel, null)
|
||||
.setPositiveButton(R.string.exit_confirm, (dialog, which) -> finish())
|
||||
.show();
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<WebView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@@ -1,13 +0,0 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">Lô tô</string>
|
||||
<string name="title_activity_main">Lô tô</string>
|
||||
<string name="package_name">com.miti99.loto</string>
|
||||
<string name="custom_url_scheme">com.miti99.loto</string>
|
||||
|
||||
<!-- Exit confirmation, shown when back is pressed with no overlay open. -->
|
||||
<string name="exit_title">Thoát Lô tô?</string>
|
||||
<string name="exit_message">Ván đang chơi sẽ được lưu lại.</string>
|
||||
<string name="exit_confirm">Thoát</string>
|
||||
<string name="exit_cancel">Ở lại</string>
|
||||
</resources>
|
||||
@@ -1,37 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<!-- Base application theme. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:background">@null</item>
|
||||
</style>
|
||||
|
||||
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||
<!-- API 31+ system splash. Colour follows light/dark via
|
||||
values-night/colors.xml. -->
|
||||
<item name="windowSplashScreenBackground">@color/splash_background</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@mipmap/ic_launcher_foreground</item>
|
||||
<!-- Hand the activity a normal AppCompat theme once the splash is
|
||||
done; Theme.SplashScreen is not meant to stay applied, and
|
||||
AppCompat dialogs need an AppCompat-derived theme. -->
|
||||
<item name="postSplashScreenTheme">@style/AppTheme.NoActionBar</item>
|
||||
<!-- Compat path for API < 31; see drawable/splash.xml. -->
|
||||
<item name="android:background">@drawable/splash</item>
|
||||
</style>
|
||||
|
||||
<!-- Exit-confirmation dialog. Declared with an explicit AppCompat parent
|
||||
because the activity theme (Theme.SplashScreen) is not guaranteed to
|
||||
be AppCompat-derived, and AppCompat's AlertDialog requires one.
|
||||
DayNight so the dialog follows the app's light/dark setting. -->
|
||||
<style name="AppTheme.ExitDialog" parent="Theme.AppCompat.DayNight.Dialog.Alert" />
|
||||
</resources>
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path name="my_images" path="." />
|
||||
<cache-path name="my_cache_images" path="." />
|
||||
</paths>
|
||||
@@ -1,18 +0,0 @@
|
||||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
public class ExampleUnitTest {
|
||||
|
||||
@Test
|
||||
public void addition_isCorrect() throws Exception {
|
||||
assertEquals(4, 2 + 2);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.13.0'
|
||||
classpath 'com.google.gms:google-services:4.4.4'
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "variables.gradle"
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
@@ -1,22 +0,0 @@
|
||||
# Project-wide Gradle settings.
|
||||
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx1536m
|
||||
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
@@ -1,5 +0,0 @@
|
||||
include ':app'
|
||||
include ':capacitor-cordova-android-plugins'
|
||||
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
||||
|
||||
apply from: 'capacitor.settings.gradle'
|
||||
@@ -1,16 +0,0 @@
|
||||
ext {
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 36
|
||||
targetSdkVersion = 36
|
||||
androidxActivityVersion = '1.11.0'
|
||||
androidxAppCompatVersion = '1.7.1'
|
||||
androidxCoordinatorLayoutVersion = '1.3.0'
|
||||
androidxCoreVersion = '1.17.0'
|
||||
androidxFragmentVersion = '1.8.9'
|
||||
coreSplashScreenVersion = '1.2.0'
|
||||
androidxWebkitVersion = '1.14.0'
|
||||
junitVersion = '4.13.2'
|
||||
androidxJunitVersion = '1.3.0'
|
||||
androidxEspressoCoreVersion = '3.7.0'
|
||||
cordovaAndroidVersion = '14.0.1'
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.miti99.loto"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.miti99.loto"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 7
|
||||
versionName = "0.2.0"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
// The app ships Vietnamese-only copy; trim library translations.
|
||||
resourceConfigurations += listOf("vi")
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
// Voice clips and their manifest are mounted straight from the web
|
||||
// app's static dir so both apps ship the exact same files — no copy
|
||||
// step, no drift. Mounts web/static/audio/** as assets/audio/**.
|
||||
assets.srcDir("../../web/static")
|
||||
}
|
||||
}
|
||||
|
||||
androidResources {
|
||||
// Keep only the audio/ subtree from the mounted web/static dir.
|
||||
// Pattern extends AAPT's default ignore list (hidden files, VCS dirs).
|
||||
ignoreAssetsPattern =
|
||||
"!icons:!manifest.webmanifest:!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
// Reads from env vars so the keystore never lands in the repo.
|
||||
// Same contract as every previous Lô tô release (CI secrets unchanged).
|
||||
create("release") {
|
||||
val keystorePath = System.getenv("LOTO_KEYSTORE_PATH")
|
||||
if (keystorePath != null) {
|
||||
storeFile = file(keystorePath)
|
||||
storeType = "pkcs12"
|
||||
storePassword = System.getenv("LOTO_KEYSTORE_PASSWORD")
|
||||
keyAlias = System.getenv("LOTO_KEY_ALIAS")
|
||||
keyPassword = System.getenv("LOTO_KEY_PASSWORD")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
// Only sign when env vars are present; debug builds stay debug-signed.
|
||||
if (System.getenv("LOTO_KEYSTORE_PATH") != null) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
implementation(libs.androidx.media3.exoplayer)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.turbine)
|
||||
// Real org.json for JVM unit tests (android.jar only ships stubs).
|
||||
testImplementation(libs.org.json)
|
||||
|
||||
androidTestImplementation(libs.androidx.test.junit)
|
||||
androidTestImplementation(libs.androidx.test.espresso.core)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
# App-specific ProGuard/R8 rules.
|
||||
# Compose, Media3 and DataStore ship their own consumer rules; nothing extra
|
||||
# is needed while the app has no reflection-based code.
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.miti99.loto
|
||||
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Master panel critical interactions: draw updates hero + history, reset
|
||||
* asks for confirmation mid-round. (Auto-call relabeling needs the
|
||||
* autoCallEnabled setting — toggled through the repository.)
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MasterPanelScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
private val app: LotoApplication
|
||||
get() = InstrumentationRegistry.getInstrumentation()
|
||||
.targetContext.applicationContext as LotoApplication
|
||||
|
||||
@Before
|
||||
fun switchToMasterMode() {
|
||||
// Reset first — instrumentation tests share the app's DataStore.
|
||||
runBlocking {
|
||||
app.settingsRepository.reset()
|
||||
app.settingsRepository.setMode(com.miti99.loto.settings.AppMode.MASTER)
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
}
|
||||
|
||||
/** Starts a round, clicking through the confirm dialog a persisted round raises. */
|
||||
private fun startNewGame() {
|
||||
composeRule.onNodeWithText("Ván mới").performClick()
|
||||
composeRule.waitForIdle()
|
||||
val confirm = composeRule.onAllNodesWithText("Đồng ý").fetchSemanticsNodes()
|
||||
if (confirm.isNotEmpty()) {
|
||||
composeRule.onNodeWithText("Đồng ý").performClick()
|
||||
composeRule.waitForIdle()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun drawUpdatesHeroAndHistory() {
|
||||
startNewGame()
|
||||
composeRule.onNodeWithText("Xổ số").performClick()
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onNodeWithText("SỐ VỪA XỔ").assertExists()
|
||||
composeRule.onNodeWithText("Thứ tự đã xổ:").assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun midRoundResetAsksForConfirmation() {
|
||||
startNewGame()
|
||||
composeRule.onNodeWithText("Xổ số").performClick()
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onNodeWithText("Ván mới").performClick()
|
||||
composeRule.onNodeWithText("Bạn có muốn tạo ván mới không?").assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun autoCallSettingRelabelsTheDrawButton() {
|
||||
runBlocking { app.settingsRepository.setAutoCallEnabled(true) }
|
||||
startNewGame()
|
||||
composeRule.onNodeWithText("Bắt đầu").performClick()
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onNodeWithText("Dừng").assertExists()
|
||||
composeRule.onNodeWithText("Dừng").performClick()
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onNodeWithText("Bắt đầu").assertExists()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.miti99.loto
|
||||
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.semantics.getOrNull
|
||||
import androidx.compose.ui.test.SemanticsMatcher
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Critical player interactions: generate a card, tap-cross a cell, drive a
|
||||
* row to chờ and then to Kinh. Cells are found via their state descriptions
|
||||
* ("Số N[, đã đánh dấu][, đang chờ]"); composition order is row-major, so
|
||||
* the first five number cells belong to row 0.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PlayerBoardScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
@Before
|
||||
fun resetSettings() {
|
||||
// Instrumentation tests share the app's DataStore across classes;
|
||||
// make sure the player board is visible regardless of run order.
|
||||
val app = InstrumentationRegistry.getInstrumentation()
|
||||
.targetContext.applicationContext as LotoApplication
|
||||
runBlocking { app.settingsRepository.reset() }
|
||||
composeRule.waitForIdle()
|
||||
}
|
||||
|
||||
private fun numberCells() = composeRule.onAllNodes(
|
||||
SemanticsMatcher("has number-cell state") {
|
||||
it.config.getOrNull(SemanticsProperties.StateDescription)?.startsWith("Số ") == true
|
||||
},
|
||||
)
|
||||
|
||||
private fun generateCard() {
|
||||
composeRule.onNodeWithText("Tạo bảng mới").performClick()
|
||||
composeRule.waitForIdle()
|
||||
// A persisted round from an earlier test raises the confirm dialog.
|
||||
val confirm = composeRule.onAllNodesWithText("Đồng ý").fetchSemanticsNodes()
|
||||
if (confirm.isNotEmpty()) {
|
||||
composeRule.onNodeWithText("Đồng ý").performClick()
|
||||
composeRule.waitForIdle()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun generateShowsA45NumberCard() {
|
||||
generateCard()
|
||||
numberCells().assertCountEquals(45)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingACellMarksIt() {
|
||||
generateCard()
|
||||
numberCells()[0].performClick()
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onAllNodes(
|
||||
SemanticsMatcher("crossed cell") {
|
||||
it.config.getOrNull(SemanticsProperties.StateDescription)
|
||||
?.contains("đã đánh dấu") == true
|
||||
},
|
||||
).assertCountEquals(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completingARowShowsChoThenKinh() {
|
||||
generateCard()
|
||||
// First five number cells are row 0's numbers (row-major order).
|
||||
repeat(4) { i -> numberCells()[i].performClick() }
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onNodeWithText("Chờ ", substring = true).assertExists()
|
||||
|
||||
numberCells()[4].performClick()
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onNodeWithText("Kinh!").assertExists()
|
||||
}
|
||||
}
|
||||
|
||||
private fun androidx.compose.ui.test.SemanticsNodeInteractionCollection.assertCountEquals(
|
||||
expected: Int,
|
||||
) = fetchSemanticsNodes().let { nodes ->
|
||||
check(nodes.size == expected) { "expected $expected nodes, found ${nodes.size}" }
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.miti99.loto
|
||||
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.espresso.Espresso
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/** Cross-cutting settings: display mode switches the root layout live. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SettingsSheetTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
private val app: LotoApplication
|
||||
get() = InstrumentationRegistry.getInstrumentation()
|
||||
.targetContext.applicationContext as LotoApplication
|
||||
|
||||
@Before
|
||||
fun resetSettings() {
|
||||
runBlocking { app.settingsRepository.reset() }
|
||||
composeRule.waitForIdle()
|
||||
}
|
||||
|
||||
private fun openSheet() {
|
||||
composeRule.onNodeWithContentDescription("Cài đặt").performClick()
|
||||
composeRule.waitForIdle()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun switchingToMasterModeSwapsThePanels() {
|
||||
composeRule.onNodeWithText("Tạo bảng mới").assertExists()
|
||||
openSheet()
|
||||
composeRule.onNodeWithText("Quản trò").performClick()
|
||||
// The write goes through DataStore; wait for the mode to land.
|
||||
composeRule.waitUntil(timeoutMillis = 5_000) {
|
||||
app.settingsState.value.mode == com.miti99.loto.settings.AppMode.MASTER
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
// Back closes the sheet (also a device-QA item); the mode change
|
||||
// already applied live.
|
||||
Espresso.pressBack()
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onNodeWithText("Ván mới").assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sheetExposesEverydaySections() {
|
||||
openSheet()
|
||||
composeRule.onNodeWithText("Giao diện").assertExists()
|
||||
composeRule.onNodeWithText("Cỡ chữ bảng").assertExists()
|
||||
composeRule.onNodeWithText("Chế độ hiển thị").assertExists()
|
||||
composeRule.onNodeWithText("Âm thanh").assertExists()
|
||||
composeRule.onNodeWithText("Màu ô trống").assertExists()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.miti99.loto
|
||||
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SmokeTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
@Test
|
||||
fun launchShowsAppName() {
|
||||
composeRule.onNodeWithText("Lô tô").assertExists()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- No INTERNET permission: the app is fully offline, all assets are
|
||||
bundled in the APK. -->
|
||||
|
||||
<!-- Haptic feedback on cell taps. Normal permission: no runtime prompt,
|
||||
no data-safety impact. -->
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<!-- Media3 ExoPlayer merges this in for network-requirement watching; the
|
||||
app plays bundled assets only, so strip it to keep the hard offline
|
||||
guarantee (VIBRATE must stay the only permission). -->
|
||||
<uses-permission
|
||||
android:name="android.permission.ACCESS_NETWORK_STATE"
|
||||
tools:node="remove" />
|
||||
|
||||
<!-- Mark hardware as optional so emulators (BlueStacks, NoxPlayer, AVDs)
|
||||
don't get filtered out on Play. BlueStacks reports `faketouch`
|
||||
instead of `touchscreen` and lacks real telephony/GPS hardware. -->
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.faketouch"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.screen.portrait"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.screen.landscape"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".LotoApplication"
|
||||
android:allowBackup="true"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="false"
|
||||
android:theme="@style/Theme.Loto">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.Loto.Splash">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.miti99.loto
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.miti99.loto.audio.ExoVoicePlayer
|
||||
import com.miti99.loto.audio.Voice
|
||||
import com.miti99.loto.audio.VoiceCatalog
|
||||
import com.miti99.loto.audio.VoicePlayerApi
|
||||
import com.miti99.loto.settings.Settings
|
||||
import com.miti99.loto.settings.SettingsRepository
|
||||
import com.miti99.loto.state.GameStateRepository
|
||||
import com.miti99.loto.state.MasterStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// Settings and round state live in separate stores (the web keeps them under
|
||||
// separate localStorage keys too) so frequent round writes never contend
|
||||
// with settings writes. A corrupt file falls back to empty preferences —
|
||||
// losing one round beats crash-looping on every launch (the web's
|
||||
// try/catch-everything localStorage posture).
|
||||
private val Context.settingsDataStore by preferencesDataStore(
|
||||
name = "loto_settings",
|
||||
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
|
||||
)
|
||||
private val Context.gameStateDataStore by preferencesDataStore(
|
||||
name = "loto_game_state",
|
||||
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
|
||||
)
|
||||
|
||||
/**
|
||||
* Process-wide singletons live here (manual DI — the app is small enough
|
||||
* that a DI framework would cost more than it saves).
|
||||
*/
|
||||
class LotoApplication : Application() {
|
||||
|
||||
val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
|
||||
/** Bundled announcer voices, parsed once from the asset manifest. */
|
||||
val voices: List<Voice> by lazy { VoiceCatalog.load(this) }
|
||||
|
||||
val settingsRepository: SettingsRepository by lazy {
|
||||
SettingsRepository(
|
||||
dataStore = settingsDataStore,
|
||||
validVoiceIds = voices.map { it.id }.toSet(),
|
||||
defaultVoiceId = VoiceCatalog.defaultVoiceId(voices),
|
||||
)
|
||||
}
|
||||
|
||||
/** App-scoped settings snapshot every ViewModel reads from. */
|
||||
val settingsState: StateFlow<Settings> by lazy {
|
||||
settingsRepository.settingsFlow
|
||||
.stateIn(appScope, SharingStarted.Eagerly, settingsRepository.defaults)
|
||||
}
|
||||
|
||||
val gameStateRepository: GameStateRepository by lazy {
|
||||
GameStateRepository(gameStateDataStore)
|
||||
}
|
||||
|
||||
val masterStore: MasterStore by lazy {
|
||||
MasterStore(gameStateRepository, appScope)
|
||||
}
|
||||
|
||||
/** Built on the main thread (ExoPlayer requirement). */
|
||||
val voicePlayer: VoicePlayerApi by lazy {
|
||||
ExoVoicePlayer(this).also { it.voiceId = VoiceCatalog.defaultVoiceId(voices) }
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
appScope.launch {
|
||||
masterStore.restore()
|
||||
// Keep the announcer on the configured voice.
|
||||
settingsState.collect { voicePlayer.voiceId = it.voice }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.miti99.loto
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
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 androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import com.miti99.loto.state.LotoViewModelFactory
|
||||
import com.miti99.loto.state.MasterPanelViewModel
|
||||
import com.miti99.loto.state.PlayerBoardViewModel
|
||||
import com.miti99.loto.state.SettingsViewModel
|
||||
import com.miti99.loto.ui.LotoAppRoot
|
||||
import com.miti99.loto.ui.settings.SettingsSheet
|
||||
import com.miti99.loto.ui.theme.LotoTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val factory by lazy { LotoViewModelFactory(application as LotoApplication) }
|
||||
private val playerViewModel: PlayerBoardViewModel by viewModels { factory }
|
||||
private val masterViewModel: MasterPanelViewModel by viewModels { factory }
|
||||
private val settingsViewModel: SettingsViewModel by viewModels { factory }
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
masterViewModel.setForeground(true)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
// Pause auto-call while backgrounded — numbers nobody hears must not
|
||||
// drain the deck (the web's hidden tab was throttled for free).
|
||||
masterViewModel.setForeground(false)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
val app = application as LotoApplication
|
||||
setContent {
|
||||
val settings by app.settingsState.collectAsState()
|
||||
var showSettings by remember { mutableStateOf(false) }
|
||||
var showExitConfirm by remember { mutableStateOf(false) }
|
||||
LotoTheme(theme = settings.theme) {
|
||||
// Compose dialogs and the settings sheet consume back in
|
||||
// their own windows; this fires only at the root.
|
||||
BackHandler { showExitConfirm = true }
|
||||
LotoAppRoot(
|
||||
settings = settings,
|
||||
playerViewModel = playerViewModel,
|
||||
masterViewModel = masterViewModel,
|
||||
settingsButton = {
|
||||
IconButton(onClick = { showSettings = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Settings,
|
||||
contentDescription = stringResource(R.string.settings_open),
|
||||
tint = LotoTheme.palette.subtitle,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
if (showSettings) {
|
||||
SettingsSheet(
|
||||
viewModel = settingsViewModel,
|
||||
onDismiss = { showSettings = false },
|
||||
)
|
||||
}
|
||||
if (showExitConfirm) {
|
||||
// Same copy as the wrapper's exit dialog; state is already
|
||||
// persisted continuously, so "Thoát" can simply finish.
|
||||
AlertDialog(
|
||||
onDismissRequest = { showExitConfirm = false },
|
||||
title = { Text(stringResource(R.string.exit_title)) },
|
||||
text = { Text(stringResource(R.string.exit_message)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { finish() }) {
|
||||
Text(stringResource(R.string.exit_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showExitConfirm = false }) {
|
||||
Text(stringResource(R.string.exit_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.miti99.loto.audio
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* A bundled announcer voice, mirroring the entries of
|
||||
* `assets/audio/manifest.json` (the same file the web app imports).
|
||||
*/
|
||||
data class Voice(
|
||||
val id: String,
|
||||
val edgeName: String,
|
||||
val label: String,
|
||||
val gender: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Catalog of the bundled voices. Clips live under
|
||||
* `assets/audio/{voiceId}/{1..90,cho,kinh}.mp3`; the manifest is produced by
|
||||
* `scripts/generate-audio.py` in the web app and shipped verbatim.
|
||||
*/
|
||||
object VoiceCatalog {
|
||||
|
||||
const val MANIFEST_ASSET_PATH = "audio/manifest.json"
|
||||
|
||||
/** Clip basename for the "Chờ" announcement. */
|
||||
const val CLIP_CHO = "cho"
|
||||
|
||||
/** Clip basename for the "Kinh" (row win) announcement. */
|
||||
const val CLIP_KINH = "kinh"
|
||||
|
||||
/**
|
||||
* Known-good voices matching the shipped assets, used when the manifest
|
||||
* is missing or malformed (mirror of the web app's defensive posture —
|
||||
* the first entry is the default voice).
|
||||
*/
|
||||
val FALLBACK_VOICES = listOf(
|
||||
Voice("hoai-my", "vi-VN-HoaiMyNeural", "Hoai My (nữ)", "female"),
|
||||
Voice("nam-minh", "vi-VN-NamMinhNeural", "Nam Minh (nam)", "male"),
|
||||
)
|
||||
|
||||
/** Parse manifest JSON; any shape problem falls back to [FALLBACK_VOICES]. */
|
||||
fun parse(json: String): List<Voice> {
|
||||
return try {
|
||||
val voicesJson = JSONObject(json).getJSONArray("voices")
|
||||
val voices = (0 until voicesJson.length()).map { i ->
|
||||
val v = voicesJson.getJSONObject(i)
|
||||
Voice(
|
||||
id = v.getString("id"),
|
||||
edgeName = v.getString("edgeName"),
|
||||
label = v.getString("label"),
|
||||
gender = v.getString("gender"),
|
||||
)
|
||||
}
|
||||
voices.ifEmpty { FALLBACK_VOICES }
|
||||
} catch (_: Exception) {
|
||||
FALLBACK_VOICES
|
||||
}
|
||||
}
|
||||
|
||||
/** Read and parse the bundled manifest; falls back on any IO/parse error. */
|
||||
fun load(context: Context): List<Voice> {
|
||||
return try {
|
||||
val json = context.assets.open(MANIFEST_ASSET_PATH)
|
||||
.bufferedReader()
|
||||
.use { it.readText() }
|
||||
parse(json)
|
||||
} catch (_: Exception) {
|
||||
FALLBACK_VOICES
|
||||
}
|
||||
}
|
||||
|
||||
/** First entry is the default — the generator writes them in edge-tts order. */
|
||||
fun defaultVoiceId(voices: List<Voice>): String =
|
||||
voices.firstOrNull()?.id ?: FALLBACK_VOICES.first().id
|
||||
|
||||
/**
|
||||
* Asset path of one clip, relative to the assets root.
|
||||
* [clipName] ∈ "1".."90" | [CLIP_CHO] | [CLIP_KINH].
|
||||
*/
|
||||
fun clipPath(voiceId: String, clipName: String): String =
|
||||
"audio/$voiceId/$clipName.mp3"
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.miti99.loto.audio
|
||||
|
||||
import android.content.Context
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
|
||||
/**
|
||||
* Playback abstraction the ViewModels depend on; unit tests substitute a
|
||||
* recording fake, the production implementation is [ExoVoicePlayer].
|
||||
*
|
||||
* Cancellation contract (ported from `web/src/lib/voice.js`): every [speak]
|
||||
* call replaces any in-flight utterance — clips never overlap or queue
|
||||
* across calls. Switching the voice takes effect on the next utterance.
|
||||
*/
|
||||
interface VoicePlayerApi {
|
||||
/** The active voice id (folder slug under `assets/audio/`). */
|
||||
var voiceId: String
|
||||
|
||||
/** Play [clipNames] as one gapless sequence, canceling any in-flight one. */
|
||||
fun speak(clipNames: List<String>)
|
||||
|
||||
/** Stop the in-flight utterance, if any. */
|
||||
fun cancel()
|
||||
|
||||
/** Release playback resources; the instance is unusable afterwards. */
|
||||
fun release()
|
||||
}
|
||||
|
||||
/** Announce a drawn number. */
|
||||
fun VoicePlayerApi.playNumber(n: Int) = speak(listOf(n.toString()))
|
||||
|
||||
/**
|
||||
* Announce "Chờ", optionally chained with the awaited number.
|
||||
* Gating ported from the web's `playWaiting`: the trailing number is
|
||||
* suppressed in both mode — the master is already calling numbers aloud, so
|
||||
* "Chờ 42" right after a "33" call confuses listeners who can't tell which
|
||||
* number is the active draw.
|
||||
*/
|
||||
fun VoicePlayerApi.playWaiting(n: Int, voiceWaitingNumber: Boolean, modeIsBoth: Boolean) =
|
||||
speak(waitingClips(n, voiceWaitingNumber, modeIsBoth))
|
||||
|
||||
/** Announce "Kinh" (row win). */
|
||||
fun VoicePlayerApi.playBingo() = speak(listOf(VoiceCatalog.CLIP_KINH))
|
||||
|
||||
/** Pure clip-sequence rule for the chờ announcement (see [playWaiting]). */
|
||||
fun waitingClips(n: Int, voiceWaitingNumber: Boolean, modeIsBoth: Boolean): List<String> =
|
||||
if (voiceWaitingNumber && !modeIsBoth) {
|
||||
listOf(VoiceCatalog.CLIP_CHO, n.toString())
|
||||
} else {
|
||||
listOf(VoiceCatalog.CLIP_CHO)
|
||||
}
|
||||
|
||||
/**
|
||||
* Media3 ExoPlayer implementation over the bundled `asset:///audio/...`
|
||||
* clips. Main-thread only (ExoPlayer requirement) — constructed and used
|
||||
* from the UI thread.
|
||||
*
|
||||
* Audio-focus decision: the clips are ~1s speech cues layered over whatever
|
||||
* the table is already listening to, so the player requests NO audio focus
|
||||
* (music keeps playing, calls behave normally) but still pauses when audio
|
||||
* would become noisy (headphones unplugged).
|
||||
*/
|
||||
class ExoVoicePlayer(context: Context) : VoicePlayerApi {
|
||||
|
||||
override var voiceId: String = VoiceCatalog.defaultVoiceId(VoiceCatalog.FALLBACK_VOICES)
|
||||
|
||||
private val player: ExoPlayer = ExoPlayer.Builder(context)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_SPEECH)
|
||||
.build(),
|
||||
/* handleAudioFocus = */ false,
|
||||
)
|
||||
.setHandleAudioBecomingNoisy(true)
|
||||
.build()
|
||||
|
||||
override fun speak(clipNames: List<String>) {
|
||||
if (clipNames.isEmpty()) return
|
||||
// Replacing the playlist stops the in-flight utterance — the native
|
||||
// equivalent of voice.js's token cancellation.
|
||||
player.setMediaItems(
|
||||
clipNames.map { clip ->
|
||||
MediaItem.fromUri("asset:///${VoiceCatalog.clipPath(voiceId, clip)}")
|
||||
},
|
||||
)
|
||||
player.prepare()
|
||||
player.play()
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
player.stop()
|
||||
player.clearMediaItems()
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
player.release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.miti99.loto.game
|
||||
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Lô tô card generation, ported one-to-one from the web app's
|
||||
* `web/src/lib/game-logic.js` (`generateGrid` and its helpers). The web
|
||||
* implementation is the behavioral spec; any change there must be mirrored
|
||||
* here.
|
||||
*/
|
||||
object CardGenerator {
|
||||
|
||||
const val NUM_ROWS = 9
|
||||
const val NUM_COLS = 9
|
||||
const val NUM_PER_ROW = 5
|
||||
|
||||
/** Number ranges for each column (0-8) in the lô tô grid. */
|
||||
private val NUM_IN_COL: List<List<Int>> = listOf(
|
||||
(1..9).toList(),
|
||||
(10..19).toList(),
|
||||
(20..29).toList(),
|
||||
(30..39).toList(),
|
||||
(40..49).toList(),
|
||||
(50..59).toList(),
|
||||
(60..69).toList(),
|
||||
(70..79).toList(),
|
||||
(80..90).toList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Generate a 9x9 lô tô grid with exactly [NUM_PER_ROW] filled cells per
|
||||
* row AND per column. Cell values: 0 = empty, >0 = number.
|
||||
*/
|
||||
fun generateGrid(random: Random = Random.Default): List<List<Int>> {
|
||||
val cell = Array(NUM_ROWS) { IntArray(NUM_COLS) }
|
||||
val colsPerRow = pickFilledCols(random)
|
||||
for (row in 0 until NUM_ROWS) {
|
||||
for (col in colsPerRow[row]) cell[row][col] = -1
|
||||
}
|
||||
for (col in 0 until NUM_COLS) {
|
||||
val picked = ArrayDeque(randomNumbersInCol(NUM_PER_ROW, col, random))
|
||||
for (row in 0 until NUM_ROWS) {
|
||||
if (cell[row][col] == -1) cell[row][col] = picked.removeFirstOrNull() ?: 0
|
||||
}
|
||||
}
|
||||
return cell.map { it.toList() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick [num] random numbers from column [col]'s range, returned ascending
|
||||
* so they sit top-to-bottom in the column (lô tô hội chợ convention).
|
||||
*/
|
||||
private fun randomNumbersInCol(num: Int, col: Int, random: Random): List<Int> {
|
||||
val arr = NUM_IN_COL[col].toMutableList()
|
||||
// Fisher-Yates, matching the web implementation exactly.
|
||||
for (i in arr.indices.reversed()) {
|
||||
if (i == 0) break
|
||||
val j = random.nextInt(i + 1)
|
||||
val tmp = arr[i]
|
||||
arr[i] = arr[j]
|
||||
arr[j] = tmp
|
||||
}
|
||||
return arr.take(num).sorted()
|
||||
}
|
||||
|
||||
/** Sorted strictly-ascending column indices contain 3 consecutive integers? */
|
||||
internal fun hasThreeInARow(cols: List<Int>): Boolean {
|
||||
for (i in 0..cols.size - 3) {
|
||||
if (cols[i + 1] == cols[i] + 1 && cols[i + 2] == cols[i] + 2) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Enumerate every k-sized combination of [arr] (preserves input order). */
|
||||
private fun combinations(arr: List<Int>, k: Int): List<List<Int>> {
|
||||
if (k == 0) return listOf(emptyList())
|
||||
if (arr.size < k) return emptyList()
|
||||
val out = mutableListOf<List<Int>>()
|
||||
for (i in 0..arr.size - k) {
|
||||
val head = arr[i]
|
||||
for (tail in combinations(arr.subList(i + 1, arr.size), k - 1)) {
|
||||
out.add(listOf(head) + tail)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* One attempt at picking the row-by-row column selection. Per-row picker
|
||||
* prefers triple-free completions; if any row's forced set is already a
|
||||
* triple (or no completion is triple-free), that row falls back to an
|
||||
* unconstrained pick so the hard column-quota invariant never breaks.
|
||||
*/
|
||||
private fun pickFilledColsOnce(random: Random): List<List<Int>> {
|
||||
val quota = IntArray(NUM_COLS) { NUM_PER_ROW }
|
||||
val result = mutableListOf<List<Int>>()
|
||||
for (row in 0 until NUM_ROWS) {
|
||||
val rowsLeft = NUM_ROWS - row
|
||||
val forced = mutableListOf<Int>()
|
||||
val candidates = mutableListOf<Int>()
|
||||
for (col in 0 until NUM_COLS) {
|
||||
if (quota[col] == rowsLeft) forced.add(col)
|
||||
else if (quota[col] > 0) candidates.add(col)
|
||||
}
|
||||
val need = NUM_PER_ROW - forced.size
|
||||
|
||||
val validCompletions = mutableListOf<List<Int>>()
|
||||
if (!hasThreeInARow(forced)) {
|
||||
for (combo in combinations(candidates, need)) {
|
||||
val merged = (forced + combo).sorted()
|
||||
if (!hasThreeInARow(merged)) validCompletions.add(merged)
|
||||
}
|
||||
}
|
||||
|
||||
val selected: List<Int>
|
||||
if (validCompletions.isNotEmpty()) {
|
||||
selected = validCompletions[random.nextInt(validCompletions.size)]
|
||||
} else {
|
||||
for (i in candidates.indices.reversed()) {
|
||||
if (i == 0) break
|
||||
val j = random.nextInt(i + 1)
|
||||
val tmp = candidates[i]
|
||||
candidates[i] = candidates[j]
|
||||
candidates[j] = tmp
|
||||
}
|
||||
selected = (forced + candidates.take(need)).sorted()
|
||||
}
|
||||
|
||||
for (col in selected) quota[col]--
|
||||
result.add(selected)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose which columns are filled in each row so that every row has
|
||||
* exactly [NUM_PER_ROW] filled cells AND every column ends up with exactly
|
||||
* [NUM_PER_ROW] filled cells. Soft constraint: no row has 3 consecutive
|
||||
* filled columns.
|
||||
*
|
||||
* Strategy: per-row picker greedily prefers triple-free completions.
|
||||
* Because early-row choices can still corner late rows into a forced
|
||||
* triple, the whole pass is wrapped in rejection sampling. If every
|
||||
* attempt fails (extremely rare), the last attempt is returned — column
|
||||
* quotas hold either way.
|
||||
*/
|
||||
private fun pickFilledCols(random: Random): List<List<Int>> {
|
||||
val maxAttempts = 200
|
||||
var last = pickFilledColsOnce(random)
|
||||
repeat(maxAttempts) {
|
||||
if (last.all { !hasThreeInARow(it) }) return last
|
||||
last = pickFilledColsOnce(random)
|
||||
}
|
||||
return last
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.miti99.loto.game
|
||||
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* The master's draw deck, ported from `web/src/lib/master-store.svelte.js`.
|
||||
* Forward-only: numbers move from [remaining] to [called] and never back;
|
||||
* a new round replaces both lists wholesale.
|
||||
*
|
||||
* Pure state holder — persistence and reactivity live in the layers above.
|
||||
*/
|
||||
class DrawDeck(private val random: Random = Random.Default) {
|
||||
|
||||
var called: List<Int> = emptyList()
|
||||
private set
|
||||
|
||||
var remaining: List<Int> = emptyList()
|
||||
private set
|
||||
|
||||
/** Start a fresh round: empty called, full shuffled remaining. */
|
||||
fun startNewGame() {
|
||||
called = emptyList()
|
||||
remaining = shuffled1to90()
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the next number from [remaining] into [called].
|
||||
* @return the drawn number, or null if exhausted.
|
||||
*/
|
||||
fun drawNext(): Int? {
|
||||
if (remaining.isEmpty()) return null
|
||||
val next = remaining.first()
|
||||
called = called + next
|
||||
remaining = remaining.drop(1)
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a previously persisted round. Callers own payload validation
|
||||
* (range/shape checks live at the persistence layer, mirroring the web's
|
||||
* `loadMaster`).
|
||||
*/
|
||||
fun restore(called: List<Int>, remaining: List<Int>) {
|
||||
this.called = called
|
||||
this.remaining = remaining
|
||||
}
|
||||
|
||||
private fun shuffled1to90(): List<Int> {
|
||||
val all = (1..90).toMutableList()
|
||||
// Fisher-Yates, matching the web implementation.
|
||||
for (i in all.indices.reversed()) {
|
||||
if (i == 0) break
|
||||
val j = random.nextInt(i + 1)
|
||||
val tmp = all[i]
|
||||
all[i] = all[j]
|
||||
all[j] = tmp
|
||||
}
|
||||
return all
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.miti99.loto.game
|
||||
|
||||
import com.miti99.loto.settings.AppMode
|
||||
|
||||
/**
|
||||
* Pure helper for the master→player auto-cross path, ported one-to-one from
|
||||
* `web/src/lib/player-auto-cross.js`. Reads the master's full call history
|
||||
* (not a single-slot bus) so player regen, "Xoá đánh dấu", and restore can
|
||||
* replay missed draws by passing `lastHandledIndex = 0`.
|
||||
*
|
||||
* The cursor advances strictly even when no cell flips (mode mismatch,
|
||||
* manual untick, off-board) so the same draw never re-fires.
|
||||
*/
|
||||
object PlayerAutoCross {
|
||||
|
||||
data class Result(
|
||||
val crossed: List<List<Boolean>>,
|
||||
val lastHandledIndex: Int,
|
||||
val changed: Boolean,
|
||||
)
|
||||
|
||||
fun applyMasterCalls(
|
||||
grid: List<List<Int>>?,
|
||||
crossed: List<List<Boolean>>,
|
||||
called: List<Int>,
|
||||
lastHandledIndex: Int,
|
||||
manualUnticks: Set<Int>,
|
||||
mode: AppMode,
|
||||
): Result {
|
||||
// Hardening beyond the web: a shrunken history means the round was
|
||||
// reset, so a stale cursor restarts from 0 instead of silently
|
||||
// disabling auto-cross for the rest of the new round.
|
||||
val cursor = if (lastHandledIndex > called.size) 0 else lastHandledIndex
|
||||
if (cursor >= called.size) {
|
||||
return Result(crossed, cursor, changed = false)
|
||||
}
|
||||
// In non-both modes the player isn't auto-following the master, but
|
||||
// the cursor still advances — otherwise toggling player→both would
|
||||
// dump the entire back-history at once. Only future draws auto-cross.
|
||||
if (mode != AppMode.BOTH || grid == null || crossed.isEmpty()) {
|
||||
return Result(crossed, called.size, changed = false)
|
||||
}
|
||||
var next = crossed
|
||||
var changed = false
|
||||
for (i in cursor until called.size) {
|
||||
val num = called[i]
|
||||
if (manualUnticks.contains(num)) continue
|
||||
val target = PlayerCard.findUncrossedCell(grid, next, num) ?: continue
|
||||
next = next.mapIndexed { ri, row ->
|
||||
if (ri == target.row) {
|
||||
row.mapIndexed { ci, v -> if (ci == target.col) true else v }
|
||||
} else {
|
||||
row
|
||||
}
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
return Result(next, called.size, changed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.miti99.loto.game
|
||||
|
||||
/**
|
||||
* Row-state helpers over a player card, ported one-to-one from
|
||||
* `web/src/lib/game-logic.js`. A card is a 9x9 grid (0 = empty cell,
|
||||
* >0 = number) plus a parallel crossed matrix.
|
||||
*
|
||||
* Row semantics: chờ (waiting) = exactly one uncrossed number remains in a
|
||||
* row; kinh (win) = every number in a row is crossed. Both are row-based,
|
||||
* never full-card.
|
||||
*/
|
||||
object PlayerCard {
|
||||
|
||||
/** Coordinates of a cell on the 9x9 grid. */
|
||||
data class Cell(val row: Int, val col: Int)
|
||||
|
||||
/**
|
||||
* Locate the first non-crossed cell holding [num] on the grid, scanning
|
||||
* rows top-down and columns left-right. Returns null when the number is
|
||||
* absent or already crossed everywhere. Used by the master→player
|
||||
* auto-tick path.
|
||||
*/
|
||||
fun findUncrossedCell(grid: List<List<Int>>, crossed: List<List<Boolean>>, num: Int): Cell? {
|
||||
if (num <= 0) return null
|
||||
for (r in grid.indices) {
|
||||
val row = grid[r]
|
||||
for (c in row.indices) {
|
||||
if (row[c] == num && crossed.getOrNull(r)?.getOrNull(c) != true) {
|
||||
return Cell(r, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Check if a row has all its numbers crossed (and has at least one number). */
|
||||
fun isRowComplete(grid: List<List<Int>>, crossed: List<List<Boolean>>, row: Int): Boolean {
|
||||
var hasNumber = false
|
||||
for (col in 0 until CardGenerator.NUM_COLS) {
|
||||
if (grid[row][col] > 0) {
|
||||
hasNumber = true
|
||||
if (crossed.getOrNull(row)?.getOrNull(col) != true) return false
|
||||
}
|
||||
}
|
||||
return hasNumber
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the single remaining uncrossed number in a row, or null when the
|
||||
* remaining count differs from 1.
|
||||
*/
|
||||
fun getWaitingNumber(grid: List<List<Int>>, crossed: List<List<Boolean>>, row: Int): Int? {
|
||||
var remaining: Int? = null
|
||||
for (col in 0 until CardGenerator.NUM_COLS) {
|
||||
if (grid[row][col] > 0 && crossed.getOrNull(row)?.getOrNull(col) != true) {
|
||||
if (remaining != null) return null
|
||||
remaining = grid[row][col]
|
||||
}
|
||||
}
|
||||
return remaining
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.miti99.loto.settings
|
||||
|
||||
/**
|
||||
* Global UI settings, mirroring the web app's `DEFAULT_SETTINGS` contract in
|
||||
* `web/src/lib/settings-store.svelte.js` — same fields, defaults, and
|
||||
* validation rules. (The web's legacy `masterMode` migration is web-only;
|
||||
* the native store starts clean.)
|
||||
*/
|
||||
data class Settings(
|
||||
/** Hex6 background of empty board cells. Default: Excel "Standard Color: Purple". */
|
||||
val emptyCellColor: String = DEFAULT_EMPTY_CELL_COLOR,
|
||||
/** AUTO follows the OS dark-mode preference; LIGHT/DARK override it. */
|
||||
val theme: ThemeSetting = ThemeSetting.AUTO,
|
||||
/** Which panels are visible: player only, master only, or both inline. */
|
||||
val mode: AppMode = AppMode.PLAYER,
|
||||
/** When true, the master "Xổ số" button becomes "Bắt đầu/Dừng" + auto interval. */
|
||||
val autoCallEnabled: Boolean = false,
|
||||
/** Auto-call interval, seconds per number. Integer 1..10. */
|
||||
val autoCallSpeed: Int = DEFAULT_AUTO_CALL_SPEED,
|
||||
/** Speak the called number aloud when master draws. */
|
||||
val voiceEnabledMaster: Boolean = true,
|
||||
/** Speak "Chờ" / "Kinh" on player events. */
|
||||
val voiceEnabledPlayer: Boolean = false,
|
||||
/** When voiceEnabledPlayer is on, also speak the awaited number after "Chờ". */
|
||||
val voiceWaitingNumber: Boolean = false,
|
||||
/** Active voice id; matches an entry in the audio manifest. */
|
||||
val voice: String,
|
||||
/** Multiplier on board number size; one of [BOARD_TEXT_SCALES]. */
|
||||
val boardTextScale: Float = DEFAULT_BOARD_TEXT_SCALE,
|
||||
) {
|
||||
companion object {
|
||||
const val DEFAULT_EMPTY_CELL_COLOR = "#7030A0"
|
||||
const val DEFAULT_AUTO_CALL_SPEED = 5
|
||||
const val DEFAULT_BOARD_TEXT_SCALE = 1f
|
||||
val AUTO_CALL_SPEED_RANGE = 1..10
|
||||
|
||||
/**
|
||||
* Board number sizes, as a multiplier on the base size. The rungs
|
||||
* need to reach genuinely large — this replaces the wrapper-era
|
||||
* textZoom pin as the in-app size control.
|
||||
*/
|
||||
val BOARD_TEXT_SCALES = listOf(0.9f, 1f, 1.15f, 1.3f)
|
||||
|
||||
val HEX6 = Regex("^#[0-9a-fA-F]{6}$")
|
||||
}
|
||||
}
|
||||
|
||||
/** Storage values match the web strings ("auto" | "light" | "dark"). */
|
||||
enum class ThemeSetting(val storageValue: String) {
|
||||
AUTO("auto"),
|
||||
LIGHT("light"),
|
||||
DARK("dark");
|
||||
|
||||
companion object {
|
||||
fun fromStorage(value: String?): ThemeSetting? =
|
||||
entries.firstOrNull { it.storageValue == value }
|
||||
}
|
||||
}
|
||||
|
||||
/** Storage values match the web strings ("player" | "master" | "both"). */
|
||||
enum class AppMode(val storageValue: String) {
|
||||
PLAYER("player"),
|
||||
MASTER("master"),
|
||||
BOTH("both");
|
||||
|
||||
companion object {
|
||||
fun fromStorage(value: String?): AppMode? =
|
||||
entries.firstOrNull { it.storageValue == value }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.miti99.loto.settings
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.core.floatPreferencesKey
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* DataStore-backed settings store. Every field is validated independently on
|
||||
* read and falls back to its own default (the web's per-field `valid*` ??
|
||||
* default pattern) — a bad value never wholesale-resets the rest.
|
||||
*
|
||||
* @param validVoiceIds allowlist from the audio manifest
|
||||
* @param defaultVoiceId first manifest entry
|
||||
*/
|
||||
class SettingsRepository(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
private val validVoiceIds: Set<String>,
|
||||
private val defaultVoiceId: String,
|
||||
) {
|
||||
|
||||
private object Keys {
|
||||
val EMPTY_CELL_COLOR = stringPreferencesKey("emptyCellColor")
|
||||
val THEME = stringPreferencesKey("theme")
|
||||
val MODE = stringPreferencesKey("mode")
|
||||
val AUTO_CALL_ENABLED = booleanPreferencesKey("autoCallEnabled")
|
||||
val AUTO_CALL_SPEED = intPreferencesKey("autoCallSpeed")
|
||||
val VOICE_ENABLED_MASTER = booleanPreferencesKey("voiceEnabledMaster")
|
||||
val VOICE_ENABLED_PLAYER = booleanPreferencesKey("voiceEnabledPlayer")
|
||||
val VOICE_WAITING_NUMBER = booleanPreferencesKey("voiceWaitingNumber")
|
||||
val VOICE = stringPreferencesKey("voice")
|
||||
val BOARD_TEXT_SCALE = floatPreferencesKey("boardTextScale")
|
||||
}
|
||||
|
||||
/** Defaults with the manifest-derived voice filled in. */
|
||||
val defaults: Settings = Settings(voice = defaultVoiceId)
|
||||
|
||||
// IO failures fall back to defaults instead of failing the app-scoped
|
||||
// stateIn collector (the web swallows every localStorage read error).
|
||||
val settingsFlow: Flow<Settings> = dataStore.data
|
||||
.catch { if (it is IOException) emit(emptyPreferences()) else throw it }
|
||||
.map { prefs -> toSettings(prefs) }
|
||||
|
||||
private fun toSettings(prefs: Preferences): Settings = Settings(
|
||||
emptyCellColor = prefs[Keys.EMPTY_CELL_COLOR]
|
||||
?.takeIf { Settings.HEX6.matches(it) }
|
||||
?: Settings.DEFAULT_EMPTY_CELL_COLOR,
|
||||
theme = ThemeSetting.fromStorage(prefs[Keys.THEME]) ?: ThemeSetting.AUTO,
|
||||
mode = AppMode.fromStorage(prefs[Keys.MODE]) ?: AppMode.PLAYER,
|
||||
autoCallEnabled = prefs[Keys.AUTO_CALL_ENABLED] ?: false,
|
||||
autoCallSpeed = prefs[Keys.AUTO_CALL_SPEED]
|
||||
?.takeIf { it in Settings.AUTO_CALL_SPEED_RANGE }
|
||||
?: Settings.DEFAULT_AUTO_CALL_SPEED,
|
||||
voiceEnabledMaster = prefs[Keys.VOICE_ENABLED_MASTER] ?: true,
|
||||
voiceEnabledPlayer = prefs[Keys.VOICE_ENABLED_PLAYER] ?: false,
|
||||
voiceWaitingNumber = prefs[Keys.VOICE_WAITING_NUMBER] ?: false,
|
||||
voice = prefs[Keys.VOICE]
|
||||
?.takeIf { validVoiceIds.contains(it) }
|
||||
?: defaultVoiceId,
|
||||
boardTextScale = prefs[Keys.BOARD_TEXT_SCALE]
|
||||
?.takeIf { scale -> Settings.BOARD_TEXT_SCALES.any { it == scale } }
|
||||
?: Settings.DEFAULT_BOARD_TEXT_SCALE,
|
||||
)
|
||||
|
||||
suspend fun setEmptyCellColor(value: String) =
|
||||
write { it[Keys.EMPTY_CELL_COLOR] = value }
|
||||
|
||||
suspend fun setTheme(value: ThemeSetting) =
|
||||
write { it[Keys.THEME] = value.storageValue }
|
||||
|
||||
suspend fun setMode(value: AppMode) =
|
||||
write { it[Keys.MODE] = value.storageValue }
|
||||
|
||||
suspend fun setAutoCallEnabled(value: Boolean) =
|
||||
write { it[Keys.AUTO_CALL_ENABLED] = value }
|
||||
|
||||
suspend fun setAutoCallSpeed(value: Int) =
|
||||
write { it[Keys.AUTO_CALL_SPEED] = value }
|
||||
|
||||
suspend fun setVoiceEnabledMaster(value: Boolean) =
|
||||
write { it[Keys.VOICE_ENABLED_MASTER] = value }
|
||||
|
||||
suspend fun setVoiceEnabledPlayer(value: Boolean) =
|
||||
write { it[Keys.VOICE_ENABLED_PLAYER] = value }
|
||||
|
||||
suspend fun setVoiceWaitingNumber(value: Boolean) =
|
||||
write { it[Keys.VOICE_WAITING_NUMBER] = value }
|
||||
|
||||
suspend fun setVoice(value: String) =
|
||||
write { it[Keys.VOICE] = value }
|
||||
|
||||
suspend fun setBoardTextScale(value: Float) =
|
||||
write { it[Keys.BOARD_TEXT_SCALE] = value }
|
||||
|
||||
/** Return every setting to its default (and persist that). */
|
||||
suspend fun reset() = write { it.clear() }
|
||||
|
||||
/** Write failures are swallowed — the app keeps working in-memory. */
|
||||
private suspend fun write(block: (MutablePreferences) -> Unit) {
|
||||
try {
|
||||
dataStore.edit(block)
|
||||
} catch (_: IOException) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.miti99.loto.state
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.miti99.loto.game.CardGenerator
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
/** The player's persisted round: card, marks, and explicit unticks. */
|
||||
data class PlayerRoundState(
|
||||
val grid: List<List<Int>>,
|
||||
val crossed: List<List<Boolean>>,
|
||||
val manualUnticks: Set<Int>,
|
||||
)
|
||||
|
||||
/** The master's persisted round: draw history and the rest of the deck. */
|
||||
data class MasterRoundState(
|
||||
val called: List<Int>,
|
||||
val remaining: List<Int>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Round-state persistence so a process death or reboot restores the exact
|
||||
* game — the native counterpart of the web's `loto_*`/`loto_master`
|
||||
* localStorage keys. Matrices are stored as compact CSV/bitmask strings.
|
||||
* Both failure classes fall back instead of crashing (the web's
|
||||
* try/catch-everything localStorage posture): malformed payloads
|
||||
* shape-validate to "no saved round", and IO errors are swallowed on write
|
||||
* and read as empty state on load.
|
||||
*/
|
||||
class GameStateRepository(private val dataStore: DataStore<Preferences>) {
|
||||
|
||||
private object Keys {
|
||||
val PLAYER_GRID = stringPreferencesKey("player_grid")
|
||||
val PLAYER_CROSSED = stringPreferencesKey("player_crossed")
|
||||
val PLAYER_MANUAL_UNTICKS = stringPreferencesKey("player_manual_unticks")
|
||||
val MASTER_CALLED = stringPreferencesKey("master_called")
|
||||
val MASTER_REMAINING = stringPreferencesKey("master_remaining")
|
||||
}
|
||||
|
||||
private val cells = CardGenerator.NUM_ROWS * CardGenerator.NUM_COLS
|
||||
|
||||
suspend fun loadPlayerState(): PlayerRoundState? {
|
||||
val prefs = readPrefs()
|
||||
val grid = parseGrid(prefs[Keys.PLAYER_GRID]) ?: return null
|
||||
val crossed = parseCrossed(prefs[Keys.PLAYER_CROSSED])
|
||||
?: grid.map { row -> row.map { false } }
|
||||
val unticks = parseNumberList(prefs[Keys.PLAYER_MANUAL_UNTICKS])?.toSet() ?: emptySet()
|
||||
return PlayerRoundState(grid, crossed, unticks)
|
||||
}
|
||||
|
||||
suspend fun savePlayerState(state: PlayerRoundState) {
|
||||
write { prefs ->
|
||||
prefs[Keys.PLAYER_GRID] = state.grid.flatten().joinToString(",")
|
||||
prefs[Keys.PLAYER_CROSSED] =
|
||||
state.crossed.flatten().joinToString("") { if (it) "1" else "0" }
|
||||
prefs[Keys.PLAYER_MANUAL_UNTICKS] = state.manualUnticks.sorted().joinToString(",")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadMasterState(): MasterRoundState? {
|
||||
val prefs = readPrefs()
|
||||
val called = parseNumberList(prefs[Keys.MASTER_CALLED]) ?: return null
|
||||
val remaining = parseNumberList(prefs[Keys.MASTER_REMAINING]) ?: return null
|
||||
return MasterRoundState(called, remaining)
|
||||
}
|
||||
|
||||
suspend fun saveMasterState(state: MasterRoundState) {
|
||||
write { prefs ->
|
||||
prefs[Keys.MASTER_CALLED] = state.called.joinToString(",")
|
||||
prefs[Keys.MASTER_REMAINING] = state.remaining.joinToString(",")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readPrefs(): Preferences = try {
|
||||
dataStore.data.first()
|
||||
} catch (_: IOException) {
|
||||
emptyPreferences()
|
||||
}
|
||||
|
||||
private suspend fun write(block: (MutablePreferences) -> Unit) {
|
||||
try {
|
||||
dataStore.edit(block)
|
||||
} catch (_: IOException) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 81 comma-separated ints, row-major; cell values 0 (empty) or 1..90. */
|
||||
private fun parseGrid(raw: String?): List<List<Int>>? {
|
||||
if (raw.isNullOrEmpty()) return null
|
||||
val nums = raw.split(",").map { it.toIntOrNull() ?: return null }
|
||||
if (nums.size != cells || nums.any { it !in 0..90 }) return null
|
||||
return nums.chunked(CardGenerator.NUM_COLS)
|
||||
}
|
||||
|
||||
/** 81-char row-major bitmask of '0'/'1'. */
|
||||
private fun parseCrossed(raw: String?): List<List<Boolean>>? {
|
||||
if (raw == null || raw.length != cells || raw.any { it != '0' && it != '1' }) return null
|
||||
return raw.map { it == '1' }.chunked(CardGenerator.NUM_COLS)
|
||||
}
|
||||
|
||||
/** Comma-separated ints, each in 1..90. Empty string = empty list. */
|
||||
private fun parseNumberList(raw: String?): List<Int>? {
|
||||
if (raw == null) return null
|
||||
if (raw.isEmpty()) return emptyList()
|
||||
val nums = raw.split(",").map { it.toIntOrNull() ?: return null }
|
||||
if (nums.any { it !in 1..90 }) return null
|
||||
return nums
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.miti99.loto.state
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.miti99.loto.LotoApplication
|
||||
|
||||
/**
|
||||
* Manual-DI ViewModel factory over the [LotoApplication] singletons — the
|
||||
* app is small enough that a DI framework would cost more than it saves.
|
||||
*/
|
||||
class LotoViewModelFactory(private val app: LotoApplication) : ViewModelProvider.Factory {
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = when {
|
||||
modelClass.isAssignableFrom(PlayerBoardViewModel::class.java) ->
|
||||
PlayerBoardViewModel(
|
||||
repository = app.gameStateRepository,
|
||||
masterStore = app.masterStore,
|
||||
settings = app.settingsState,
|
||||
voicePlayer = app.voicePlayer,
|
||||
) as T
|
||||
|
||||
modelClass.isAssignableFrom(MasterPanelViewModel::class.java) ->
|
||||
MasterPanelViewModel(
|
||||
masterStore = app.masterStore,
|
||||
settings = app.settingsState,
|
||||
voicePlayer = app.voicePlayer,
|
||||
) as T
|
||||
|
||||
modelClass.isAssignableFrom(SettingsViewModel::class.java) ->
|
||||
SettingsViewModel(
|
||||
repository = app.settingsRepository,
|
||||
settings = app.settingsState,
|
||||
voices = app.voices,
|
||||
) as T
|
||||
|
||||
else -> throw IllegalArgumentException("Unknown ViewModel class: $modelClass")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.miti99.loto.state
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.miti99.loto.audio.VoicePlayerApi
|
||||
import com.miti99.loto.audio.playNumber
|
||||
import com.miti99.loto.settings.Settings
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* State and commands for the master (caller) panel, ported from
|
||||
* `web/src/lib/MasterPanel.svelte`. Deck data lives in the shared
|
||||
* [MasterStore]; this ViewModel adds the auto-call ticker, voice
|
||||
* announcements, and the countdown reset signal.
|
||||
*/
|
||||
class MasterPanelViewModel(
|
||||
private val masterStore: MasterStore,
|
||||
private val settings: StateFlow<Settings>,
|
||||
private val voicePlayer: VoicePlayerApi,
|
||||
) : ViewModel() {
|
||||
|
||||
/** Master round data (called history + remaining deck). */
|
||||
val masterState: StateFlow<MasterRoundState> = masterStore.state
|
||||
|
||||
private val _autoRunning = MutableStateFlow(false)
|
||||
val autoRunning: StateFlow<Boolean> = _autoRunning
|
||||
|
||||
// Whether the UI is visible. The web's ticker paused for free when the
|
||||
// page hid (throttled/paused WebView); a native ViewModel outlives
|
||||
// onStop, so the Activity drives this flag. Backgrounding pauses the
|
||||
// ticker without flipping autoRunning — it resumes on return.
|
||||
private val _foreground = MutableStateFlow(true)
|
||||
|
||||
/**
|
||||
* Bumped on each draw — signals the countdown ring to re-baseline
|
||||
* (the web's `tickCount`).
|
||||
*/
|
||||
private val _tickKey = MutableStateFlow(0)
|
||||
val tickKey: StateFlow<Int> = _tickKey
|
||||
|
||||
init {
|
||||
// Auto-call ticker. collectLatest tears down and re-arms the loop
|
||||
// whenever autoRunning or the relevant settings change — the native
|
||||
// equivalent of the web's single $effect over
|
||||
// (autoRunning, autoCallEnabled, autoCallSpeed). Disabling the auto
|
||||
// setting mid-run also stops the run.
|
||||
viewModelScope.launch {
|
||||
combine(_autoRunning, _foreground, settings) { running, foreground, s ->
|
||||
Quad(running, foreground, s.autoCallEnabled, s.autoCallSpeed)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { (running, foreground, enabled, speed) ->
|
||||
if (!running) return@collectLatest
|
||||
if (!enabled) {
|
||||
_autoRunning.value = false
|
||||
return@collectLatest
|
||||
}
|
||||
if (!foreground) return@collectLatest
|
||||
while (true) {
|
||||
delay(speed * 1000L)
|
||||
if (masterStore.state.value.remaining.isEmpty()) {
|
||||
_autoRunning.value = false
|
||||
break
|
||||
}
|
||||
drawNext()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Start a fresh round (the UI owns the confirmation dialog). */
|
||||
fun newGame() {
|
||||
voicePlayer.cancel()
|
||||
_autoRunning.value = false
|
||||
masterStore.startNewGame()
|
||||
}
|
||||
|
||||
/** Draw the next number and announce it when the master voice is on. */
|
||||
fun drawNext() {
|
||||
val next = masterStore.drawNext() ?: return
|
||||
_tickKey.value += 1
|
||||
if (settings.value.voiceEnabledMaster) {
|
||||
voicePlayer.playNumber(next)
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle the auto-call run; no-op when the deck is exhausted. */
|
||||
fun toggleAuto() {
|
||||
if (masterStore.state.value.remaining.isEmpty()) return
|
||||
_autoRunning.value = !_autoRunning.value
|
||||
}
|
||||
|
||||
/** Driven by the Activity's onStart/onStop (see the ticker note above). */
|
||||
fun setForeground(foreground: Boolean) {
|
||||
_foreground.value = foreground
|
||||
if (!foreground) voicePlayer.cancel()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
voicePlayer.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private data class Quad(
|
||||
val running: Boolean,
|
||||
val foreground: Boolean,
|
||||
val enabled: Boolean,
|
||||
val speed: Int,
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.miti99.loto.state
|
||||
|
||||
import com.miti99.loto.game.DrawDeck
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Shared reactive state for the master draw deck — the native counterpart of
|
||||
* `web/src/lib/master-store.svelte.js`. Application-scoped so the player side
|
||||
* reads the full `called` history directly (no single-slot bus that loses
|
||||
* history). Persists through [GameStateRepository] on every mutation.
|
||||
*/
|
||||
class MasterStore(
|
||||
private val repository: GameStateRepository,
|
||||
private val scope: CoroutineScope,
|
||||
private val deck: DrawDeck = DrawDeck(),
|
||||
) {
|
||||
|
||||
private val _state = MutableStateFlow(MasterRoundState(emptyList(), emptyList()))
|
||||
val state: StateFlow<MasterRoundState> = _state
|
||||
|
||||
/** Restore the persisted round, if any. Call once on startup. */
|
||||
suspend fun restore() {
|
||||
val saved = repository.loadMasterState() ?: return
|
||||
deck.restore(saved.called, saved.remaining)
|
||||
_state.value = saved
|
||||
}
|
||||
|
||||
/** Start a fresh round: empty called, full shuffled remaining. */
|
||||
fun startNewGame() {
|
||||
deck.startNewGame()
|
||||
publishAndSave()
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the next number from remaining into called.
|
||||
* @return the drawn number, or null if exhausted.
|
||||
*/
|
||||
fun drawNext(): Int? {
|
||||
val next = deck.drawNext() ?: return null
|
||||
publishAndSave()
|
||||
return next
|
||||
}
|
||||
|
||||
private fun publishAndSave() {
|
||||
val snapshot = MasterRoundState(deck.called, deck.remaining)
|
||||
_state.value = snapshot
|
||||
scope.launch { repository.saveMasterState(snapshot) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
package com.miti99.loto.state
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.miti99.loto.audio.VoicePlayerApi
|
||||
import com.miti99.loto.audio.playBingo
|
||||
import com.miti99.loto.audio.playWaiting
|
||||
import com.miti99.loto.game.CardGenerator
|
||||
import com.miti99.loto.game.PlayerAutoCross
|
||||
import com.miti99.loto.game.PlayerCard
|
||||
import com.miti99.loto.settings.AppMode
|
||||
import com.miti99.loto.settings.Settings
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Immutable UI state of the player board. Derived fields (row completeness,
|
||||
* waiting cells, section rings) are recomputed on every publish, mirroring
|
||||
* the web's `$derived` blocks in `PlayerBoard.svelte`.
|
||||
*/
|
||||
data class PlayerUiState(
|
||||
val grid: List<List<Int>>? = null,
|
||||
val crossed: List<List<Boolean>> = emptyList(),
|
||||
val rowComplete: List<Boolean> = emptyList(),
|
||||
/** Cells holding the awaited number of a waiting row (drives the pulse). */
|
||||
val waitingCells: Set<PlayerCard.Cell> = emptySet(),
|
||||
/** Per 3-row section (Tân Tân card bands): any row inside is waiting. */
|
||||
val sectionWaiting: List<Boolean> = listOf(false, false, false),
|
||||
/** Transient "Chờ N" chip text, auto-dismissed after 5s. */
|
||||
val toast: String? = null,
|
||||
val showCongrats: Boolean = false,
|
||||
/** 1-based row number shown in the Kinh modal. */
|
||||
val congratsRow: Int = 0,
|
||||
/** 1 = normal, 2 = confetti celebration. */
|
||||
val celebrationTier: Int = 1,
|
||||
)
|
||||
|
||||
/**
|
||||
* State and commands for the player board, ported from
|
||||
* `web/src/lib/PlayerBoard.svelte`: cell toggling with manual-untick
|
||||
* tracking, master auto-cross replay, chờ/kinh detection with voice + toast,
|
||||
* and round persistence.
|
||||
*/
|
||||
class PlayerBoardViewModel(
|
||||
private val repository: GameStateRepository,
|
||||
private val masterStore: MasterStore,
|
||||
private val settings: StateFlow<Settings>,
|
||||
private val voicePlayer: VoicePlayerApi,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(PlayerUiState())
|
||||
val uiState: StateFlow<PlayerUiState> = _uiState
|
||||
|
||||
private var grid: List<List<Int>>? = null
|
||||
private var crossed: List<List<Boolean>> = emptyList()
|
||||
private val manualUnticks = mutableSetOf<Int>()
|
||||
private val celebratedRows = mutableSetOf<Int>()
|
||||
private val notifiedWaitingRows = mutableSetOf<Int>()
|
||||
|
||||
// How many entries of the master's called[] were already replayed.
|
||||
// Advances strictly even on no-op passes so a single draw never
|
||||
// re-fires. Stays 0 across restore so the full history replays
|
||||
// idempotently (already-crossed cells are skipped).
|
||||
private var lastHandledIndex = 0
|
||||
|
||||
// Tracks called.length across emissions to detect a master "Ván mới"
|
||||
// (length transitions >0 → 0), which force-clears player marks in both
|
||||
// mode per locked product decision.
|
||||
private var prevCalledLen = 0
|
||||
|
||||
private var toastJob: Job? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
restore()
|
||||
// Collect after restore so the replay sees the loaded grid.
|
||||
masterStore.state.collect { master -> onMasterState(master) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun restore() {
|
||||
val saved = repository.loadPlayerState() ?: return
|
||||
grid = saved.grid
|
||||
crossed = saved.crossed
|
||||
manualUnticks.clear()
|
||||
manualUnticks.addAll(saved.manualUnticks)
|
||||
// Seed the row trackers so restoring a finished/waiting row does not
|
||||
// re-announce it.
|
||||
celebratedRows.clear()
|
||||
notifiedWaitingRows.clear()
|
||||
for (i in saved.grid.indices) {
|
||||
if (PlayerCard.isRowComplete(saved.grid, saved.crossed, i)) celebratedRows.add(i)
|
||||
if (PlayerCard.getWaitingNumber(saved.grid, saved.crossed, i) != null) {
|
||||
notifiedWaitingRows.add(i)
|
||||
}
|
||||
}
|
||||
publish()
|
||||
}
|
||||
|
||||
private fun onMasterState(master: MasterRoundState) {
|
||||
val len = master.called.size
|
||||
val prev = prevCalledLen
|
||||
prevCalledLen = len
|
||||
// Master "Ván mới": force-clear player marks in both mode. Keyed on
|
||||
// any shrink (not only ==0) so a reset conflated with the new
|
||||
// round's first draws is still detected; the fall-through replay
|
||||
// then applies those draws to the cleared board.
|
||||
if (prev > 0 && len < prev && settings.value.mode == AppMode.BOTH && grid != null) {
|
||||
crossed = grid!!.map { row -> row.map { false } }
|
||||
manualUnticks.clear()
|
||||
lastHandledIndex = 0
|
||||
celebratedRows.clear()
|
||||
notifiedWaitingRows.clear()
|
||||
persist()
|
||||
publish()
|
||||
}
|
||||
val result = PlayerAutoCross.applyMasterCalls(
|
||||
grid = grid,
|
||||
crossed = crossed,
|
||||
called = master.called,
|
||||
lastHandledIndex = lastHandledIndex,
|
||||
manualUnticks = manualUnticks,
|
||||
mode = settings.value.mode,
|
||||
)
|
||||
lastHandledIndex = result.lastHandledIndex
|
||||
if (result.changed) {
|
||||
crossed = result.crossed
|
||||
persist()
|
||||
detectRowEvents()
|
||||
publish()
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate a fresh card (the UI owns the confirmation dialog). */
|
||||
fun generate() {
|
||||
voicePlayer.cancel()
|
||||
val newGrid = CardGenerator.generateGrid()
|
||||
var newCrossed = newGrid.map { row -> row.map { false } }
|
||||
manualUnticks.clear()
|
||||
// Replay the master's called[] onto the fresh grid so the host
|
||||
// doesn't restart from zero when regenerating mid-game (locked
|
||||
// decision); outside both mode just advance the cursor.
|
||||
if (settings.value.mode == AppMode.BOTH) {
|
||||
val result = PlayerAutoCross.applyMasterCalls(
|
||||
grid = newGrid,
|
||||
crossed = newCrossed,
|
||||
called = masterStore.state.value.called,
|
||||
lastHandledIndex = 0,
|
||||
manualUnticks = manualUnticks,
|
||||
mode = AppMode.BOTH,
|
||||
)
|
||||
newCrossed = result.crossed
|
||||
lastHandledIndex = result.lastHandledIndex
|
||||
} else {
|
||||
lastHandledIndex = masterStore.state.value.called.size
|
||||
}
|
||||
grid = newGrid
|
||||
crossed = newCrossed
|
||||
celebratedRows.clear()
|
||||
notifiedWaitingRows.clear()
|
||||
dismissToast()
|
||||
persist()
|
||||
publish(showCongrats = false)
|
||||
// The replay itself can complete a row or land one in chờ — the web
|
||||
// gets this re-check for free from its reactive effect.
|
||||
detectRowEvents()
|
||||
}
|
||||
|
||||
/** Clear all marks (the UI owns the confirmation dialog). */
|
||||
fun clearMarks() {
|
||||
val g = grid ?: return
|
||||
voicePlayer.cancel()
|
||||
var cleared = g.map { row -> row.map { false } }
|
||||
manualUnticks.clear()
|
||||
// In both mode, immediately replay the master's called[] (locked
|
||||
// decision: clear → re-cross all currently-called numbers).
|
||||
if (settings.value.mode == AppMode.BOTH) {
|
||||
val result = PlayerAutoCross.applyMasterCalls(
|
||||
grid = g,
|
||||
crossed = cleared,
|
||||
called = masterStore.state.value.called,
|
||||
lastHandledIndex = 0,
|
||||
manualUnticks = manualUnticks,
|
||||
mode = AppMode.BOTH,
|
||||
)
|
||||
cleared = result.crossed
|
||||
lastHandledIndex = result.lastHandledIndex
|
||||
} else {
|
||||
lastHandledIndex = masterStore.state.value.called.size
|
||||
}
|
||||
crossed = cleared
|
||||
celebratedRows.clear()
|
||||
notifiedWaitingRows.clear()
|
||||
dismissToast()
|
||||
persist()
|
||||
publish(showCongrats = false)
|
||||
// See generate(): the clear-replay can immediately re-complete rows.
|
||||
detectRowEvents()
|
||||
}
|
||||
|
||||
/** Toggle one cell (haptics live in the UI layer). */
|
||||
fun onCellClick(row: Int, col: Int) {
|
||||
val g = grid ?: return
|
||||
val num = g[row][col]
|
||||
if (num <= 0) return
|
||||
val wasCrossed = crossed.getOrNull(row)?.getOrNull(col) == true
|
||||
// Track manual unticks of called numbers so future regen/clear
|
||||
// replays skip them; re-ticking removes the suppression. Uncalled
|
||||
// numbers are not tracked — auto-cross only acts on called ones.
|
||||
if (masterStore.state.value.called.contains(num)) {
|
||||
if (wasCrossed) manualUnticks.add(num) else manualUnticks.remove(num)
|
||||
}
|
||||
crossed = crossed.mapIndexed { ri, r ->
|
||||
if (ri == row) r.mapIndexed { ci, v -> if (ci == col) !v else v } else r
|
||||
}
|
||||
persist()
|
||||
detectRowEvents()
|
||||
publish()
|
||||
}
|
||||
|
||||
fun dismissToast() {
|
||||
toastJob?.cancel()
|
||||
toastJob = null
|
||||
if (_uiState.value.toast != null) {
|
||||
_uiState.value = _uiState.value.copy(toast = null)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissCongrats() {
|
||||
if (_uiState.value.showCongrats) {
|
||||
_uiState.value = _uiState.value.copy(showCongrats = false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
voicePlayer.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect newly completed and newly waiting rows, mirroring the web's
|
||||
* two-pass $effect: at most one Kinh modal per change, then toast +
|
||||
* voice for each newly waiting row.
|
||||
*/
|
||||
private fun detectRowEvents() {
|
||||
val g = grid ?: return
|
||||
if (crossed.isEmpty()) return
|
||||
val s = settings.value
|
||||
// The master takes over announcer duties in both mode, so its voice
|
||||
// flag also drives Chờ/Kinh. Solo players keep their own flag.
|
||||
val announce = s.voiceEnabledPlayer || (s.voiceEnabledMaster && s.mode == AppMode.BOTH)
|
||||
|
||||
// Pass 1: at most one bingo popup per change.
|
||||
for (i in g.indices) {
|
||||
if (i in celebratedRows || !PlayerCard.isRowComplete(g, crossed, i)) continue
|
||||
celebratedRows.add(i)
|
||||
notifiedWaitingRows.add(i)
|
||||
// Tier 2 confetti: 2nd bingo, OR 1st bingo while another row is
|
||||
// one cell away — the old "3+ bingos" threshold rarely fired on
|
||||
// a 9-row card, leaving most wins under-celebrated.
|
||||
val hasActiveCho = g.indices.any { r ->
|
||||
r !in celebratedRows && PlayerCard.getWaitingNumber(g, crossed, r) != null
|
||||
}
|
||||
val tier = if (celebratedRows.size >= 2 || hasActiveCho) 2 else 1
|
||||
_uiState.value = _uiState.value.copy(
|
||||
showCongrats = true,
|
||||
congratsRow = i + 1,
|
||||
celebrationTier = tier,
|
||||
)
|
||||
if (announce) voicePlayer.playBingo()
|
||||
break
|
||||
}
|
||||
|
||||
// Pass 2: update waiting state for every non-celebrated row.
|
||||
for (i in g.indices) {
|
||||
if (i in celebratedRows) continue
|
||||
val waitNum = PlayerCard.getWaitingNumber(g, crossed, i)
|
||||
if (waitNum != null && i !in notifiedWaitingRows) {
|
||||
notifiedWaitingRows.add(i)
|
||||
showToast("Chờ $waitNum")
|
||||
if (announce) {
|
||||
voicePlayer.playWaiting(
|
||||
waitNum,
|
||||
voiceWaitingNumber = s.voiceWaitingNumber,
|
||||
modeIsBoth = s.mode == AppMode.BOTH,
|
||||
)
|
||||
}
|
||||
} else if (waitNum == null && i in notifiedWaitingRows) {
|
||||
notifiedWaitingRows.remove(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showToast(message: String) {
|
||||
toastJob?.cancel()
|
||||
_uiState.value = _uiState.value.copy(toast = message)
|
||||
toastJob = viewModelScope.launch {
|
||||
delay(TOAST_DURATION_MS)
|
||||
_uiState.value = _uiState.value.copy(toast = null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun persist() {
|
||||
val g = grid ?: return
|
||||
val snapshot = PlayerRoundState(g, crossed, manualUnticks.toSet())
|
||||
viewModelScope.launch { repository.savePlayerState(snapshot) }
|
||||
}
|
||||
|
||||
private fun publish(showCongrats: Boolean? = null) {
|
||||
val g = grid
|
||||
if (g == null || crossed.isEmpty()) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
grid = g,
|
||||
crossed = crossed,
|
||||
rowComplete = emptyList(),
|
||||
waitingCells = emptySet(),
|
||||
sectionWaiting = listOf(false, false, false),
|
||||
showCongrats = showCongrats ?: _uiState.value.showCongrats,
|
||||
)
|
||||
return
|
||||
}
|
||||
val rowComplete = g.indices.map { PlayerCard.isRowComplete(g, crossed, it) }
|
||||
val waitingRows = g.indices.map { r ->
|
||||
!rowComplete[r] && PlayerCard.getWaitingNumber(g, crossed, r) != null
|
||||
}
|
||||
val waitingCells = buildSet {
|
||||
for (r in g.indices) {
|
||||
if (rowComplete[r]) continue
|
||||
val num = PlayerCard.getWaitingNumber(g, crossed, r) ?: continue
|
||||
val c = g[r].indexOf(num)
|
||||
if (c >= 0) add(PlayerCard.Cell(r, c))
|
||||
}
|
||||
}
|
||||
// Tân Tân physical card: 3 stacked 3-row sections; a section band
|
||||
// rings while any of its rows is waiting.
|
||||
val sectionWaiting = listOf(0, 3, 6).map { start ->
|
||||
(start until start + 3).any { waitingRows.getOrNull(it) == true }
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(
|
||||
grid = g,
|
||||
crossed = crossed,
|
||||
rowComplete = rowComplete,
|
||||
waitingCells = waitingCells,
|
||||
sectionWaiting = sectionWaiting,
|
||||
showCongrats = showCongrats ?: _uiState.value.showCongrats,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TOAST_DURATION_MS = 5_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.miti99.loto.state
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.miti99.loto.audio.Voice
|
||||
import com.miti99.loto.settings.AppMode
|
||||
import com.miti99.loto.settings.Settings
|
||||
import com.miti99.loto.settings.SettingsRepository
|
||||
import com.miti99.loto.settings.ThemeSetting
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Bridges the settings sheet UI to [SettingsRepository]. Reads come from the
|
||||
* app-scoped [settings] state; writes go through the repository (validation
|
||||
* happens on read, mirroring the web store).
|
||||
*/
|
||||
class SettingsViewModel(
|
||||
private val repository: SettingsRepository,
|
||||
val settings: StateFlow<Settings>,
|
||||
/** Bundled voices for the voice picker. */
|
||||
val voices: List<Voice>,
|
||||
) : ViewModel() {
|
||||
|
||||
fun setEmptyCellColor(value: String) = update { repository.setEmptyCellColor(value) }
|
||||
fun setTheme(value: ThemeSetting) = update { repository.setTheme(value) }
|
||||
fun setMode(value: AppMode) = update { repository.setMode(value) }
|
||||
fun setAutoCallEnabled(value: Boolean) = update { repository.setAutoCallEnabled(value) }
|
||||
fun setAutoCallSpeed(value: Int) = update { repository.setAutoCallSpeed(value) }
|
||||
fun setVoiceEnabledMaster(value: Boolean) = update { repository.setVoiceEnabledMaster(value) }
|
||||
fun setVoiceEnabledPlayer(value: Boolean) = update { repository.setVoiceEnabledPlayer(value) }
|
||||
fun setVoiceWaitingNumber(value: Boolean) = update { repository.setVoiceWaitingNumber(value) }
|
||||
fun setVoice(value: String) = update { repository.setVoice(value) }
|
||||
fun setBoardTextScale(value: Float) = update { repository.setBoardTextScale(value) }
|
||||
fun reset() = update { repository.reset() }
|
||||
|
||||
private fun update(block: suspend () -> Unit) {
|
||||
viewModelScope.launch { block() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.miti99.loto.ui
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* Parse a hex6 setting value ("#RRGGBB") into a Compose color. The settings
|
||||
* layer already validates the format; the fallback guards direct callers.
|
||||
*/
|
||||
fun String.toComposeColor(): Color = try {
|
||||
Color(android.graphics.Color.parseColor(this))
|
||||
} catch (_: IllegalArgumentException) {
|
||||
Color(0xFF7030A0)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.miti99.loto.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import com.miti99.loto.settings.AppMode
|
||||
|
||||
/**
|
||||
* Whether the screen must stay awake: a master round is still being drawn
|
||||
* (auto-call advances with no touch input, so the display would otherwise
|
||||
* sleep mid-round). Keyed on remaining — a finished board releases the
|
||||
* screen — and never held in player-only mode, where the player's own taps
|
||||
* keep the screen alive (web `wake-lock` parity).
|
||||
*/
|
||||
fun shouldKeepScreenOn(mode: AppMode, remainingCount: Int): Boolean =
|
||||
mode != AppMode.PLAYER && remainingCount > 0
|
||||
|
||||
/** Holds the window's keep-screen-on flag while [enabled]; releases on leave. */
|
||||
@Composable
|
||||
fun KeepScreenOn(enabled: Boolean) {
|
||||
val view = LocalView.current
|
||||
DisposableEffect(enabled) {
|
||||
view.keepScreenOn = enabled
|
||||
onDispose { view.keepScreenOn = false }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.miti99.loto.ui
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.net.toUri
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.miti99.loto.R
|
||||
import com.miti99.loto.settings.AppMode
|
||||
import com.miti99.loto.settings.Settings
|
||||
import com.miti99.loto.state.MasterPanelViewModel
|
||||
import com.miti99.loto.state.PlayerBoardViewModel
|
||||
import com.miti99.loto.ui.master.MasterPanelScreen
|
||||
import com.miti99.loto.ui.player.PlayerBoardScreen
|
||||
import com.miti99.loto.ui.theme.LotoTheme
|
||||
|
||||
/**
|
||||
* Page composition, ported from `web/src/routes/+page.svelte`: header,
|
||||
* player board (mode ≠ master), master section (mode ≠ player), footer —
|
||||
* one shared scroll, content capped at the web's max-w-2xl.
|
||||
*/
|
||||
@Composable
|
||||
fun LotoAppRoot(
|
||||
settings: Settings,
|
||||
playerViewModel: PlayerBoardViewModel,
|
||||
masterViewModel: MasterPanelViewModel,
|
||||
settingsButton: @Composable () -> Unit = {},
|
||||
) {
|
||||
val palette = LotoTheme.palette
|
||||
// Hold the screen awake while numbers are still to be called.
|
||||
val master by masterViewModel.masterState.collectAsState()
|
||||
KeepScreenOn(enabled = shouldKeepScreenOn(settings.mode, master.remaining.size))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(palette.background)
|
||||
.safeDrawingPadding(),
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 8.dp, vertical = 16.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.widthIn(max = 672.dp)) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
PageHeader()
|
||||
Box(modifier = Modifier.align(Alignment.TopEnd)) {
|
||||
settingsButton()
|
||||
}
|
||||
}
|
||||
if (settings.mode != AppMode.MASTER) {
|
||||
PlayerBoardScreen(viewModel = playerViewModel, settings = settings)
|
||||
}
|
||||
if (settings.mode != AppMode.PLAYER) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = if (settings.mode == AppMode.BOTH) 40.dp else 0.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.master_heading).uppercase(),
|
||||
color = palette.masterHeading,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 0.1.em,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
)
|
||||
MasterPanelScreen(viewModel = masterViewModel, settings = settings)
|
||||
}
|
||||
}
|
||||
PageFooter()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PageHeader() {
|
||||
val palette = LotoTheme.palette
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
color = palette.title,
|
||||
fontSize = 48.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
fontStyle = FontStyle.Italic,
|
||||
letterSpacing = (-1).sp,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
HeaderRule()
|
||||
Text(
|
||||
text = stringResource(R.string.app_subtitle),
|
||||
color = palette.subtitle,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
)
|
||||
HeaderRule()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeaderRule() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(24.dp)
|
||||
.height(1.dp)
|
||||
.background(LotoTheme.palette.subtitle.copy(alpha = 0.6f)),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PageFooter() {
|
||||
val palette = LotoTheme.palette
|
||||
val context = LocalContext.current
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 32.dp, bottom = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.footer_inspiration),
|
||||
color = palette.subtitle,
|
||||
fontSize = 13.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.footer_credit),
|
||||
color = palette.subtitle,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier
|
||||
.padding(top = 8.dp)
|
||||
.clickable {
|
||||
// Opens the browser; the app itself stays offline-only.
|
||||
runCatching {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, "https://miti99.com".toUri()),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.miti99.loto.ui
|
||||
|
||||
import android.provider.Settings
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
/**
|
||||
* Android's analog of the web's `prefers-reduced-motion`: animations are
|
||||
* globally disabled when the animator duration scale is 0 (system
|
||||
* "Remove animations" accessibility setting). Gates decorative pulses,
|
||||
* confetti, and tap haptics — same events the web suppresses.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberReducedMotion(): Boolean {
|
||||
val context = LocalContext.current
|
||||
return remember {
|
||||
Settings.Global.getFloat(
|
||||
context.contentResolver,
|
||||
Settings.Global.ANIMATOR_DURATION_SCALE,
|
||||
1f,
|
||||
) == 0f
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.miti99.loto.ui.master
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.miti99.loto.R
|
||||
import com.miti99.loto.ui.theme.LotoTheme
|
||||
import kotlin.math.ceil
|
||||
|
||||
/**
|
||||
* Visual countdown ring for auto-call, ported from `AutoCountdown.svelte`.
|
||||
* Render-only: the ViewModel owns the draw timer; this animation is a
|
||||
* presentation clock re-baselined on every [tickKey] bump, `running` rising
|
||||
* edge, or [durationSeconds] change — never a second draw timer.
|
||||
*/
|
||||
@Composable
|
||||
fun AutoCountdown(
|
||||
running: Boolean,
|
||||
durationSeconds: Int,
|
||||
tickKey: Int,
|
||||
reducedMotion: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val palette = LotoTheme.palette
|
||||
// Wall-clock frame loop rather than an animation: Compose animations
|
||||
// scale with the system animator duration setting, which would complete
|
||||
// the tween instantly (and freeze the number) when animations are off.
|
||||
var progress by remember { mutableFloatStateOf(0f) }
|
||||
LaunchedEffect(running, durationSeconds, tickKey) {
|
||||
progress = 0f
|
||||
if (!running) return@LaunchedEffect
|
||||
val startNanos = withFrameNanos { it }
|
||||
val totalNanos = durationSeconds * 1_000_000_000L
|
||||
while (progress < 1f) {
|
||||
withFrameNanos { now ->
|
||||
progress = ((now - startNanos).toFloat() / totalNanos).coerceIn(0f, 1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clamp to [1, duration] while running — avoids flashing 0 between the
|
||||
// timer edge and the tickKey bump (same clamp as the web).
|
||||
val secondsRemaining = if (running) {
|
||||
ceil(durationSeconds * (1f - progress)).toInt().coerceIn(1, durationSeconds)
|
||||
} else {
|
||||
durationSeconds
|
||||
}
|
||||
val label = stringResource(R.string.master_countdown, secondsRemaining)
|
||||
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier
|
||||
.size(88.dp)
|
||||
.semantics { contentDescription = label },
|
||||
) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val stroke = 8.dp.toPx()
|
||||
val inset = stroke / 2
|
||||
drawArc(
|
||||
color = palette.countdownTrack,
|
||||
startAngle = 0f,
|
||||
sweepAngle = 360f,
|
||||
useCenter = false,
|
||||
style = Stroke(stroke),
|
||||
topLeft = androidx.compose.ui.geometry.Offset(inset, inset),
|
||||
size = androidx.compose.ui.geometry.Size(size.width - stroke, size.height - stroke),
|
||||
)
|
||||
// Reduced motion keeps a static full ring; the number still
|
||||
// ticks because the frame loop above is wall-clock based.
|
||||
val sweep = if (reducedMotion) 360f else 360f * (1f - progress)
|
||||
drawArc(
|
||||
color = palette.countdownArc,
|
||||
startAngle = -90f,
|
||||
sweepAngle = sweep,
|
||||
useCenter = false,
|
||||
style = Stroke(stroke, cap = StrokeCap.Round),
|
||||
topLeft = androidx.compose.ui.geometry.Offset(inset, inset),
|
||||
size = androidx.compose.ui.geometry.Size(size.width - stroke, size.height - stroke),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = secondsRemaining.toString(),
|
||||
color = palette.foreground,
|
||||
fontSize = 26.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.miti99.loto.ui.master
|
||||
|
||||
/**
|
||||
* The 11×9 master tracking board, aligned by ones digit — ported from the
|
||||
* `buildBoard()` module block of `web/src/lib/MasterPanel.svelte`.
|
||||
* Row = ones digit, col = tens digit. Col 0 holds 1..9, col 8 holds 80..90
|
||||
* (90 sits alone in row 10, col 8). Empty slots = 0.
|
||||
*/
|
||||
object MasterBoardLayout {
|
||||
|
||||
const val ROWS = 11
|
||||
const val COLS = 9
|
||||
|
||||
val BOARD: List<List<Int>> = List(ROWS) { row ->
|
||||
List(COLS) { col ->
|
||||
when {
|
||||
row == 10 -> if (col == 8) 90 else 0
|
||||
row == 0 -> if (col > 0) col * 10 else 0
|
||||
else -> if (col == 0) row else col * 10 + row
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.miti99.loto.ui.master
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.miti99.loto.R
|
||||
import com.miti99.loto.settings.Settings
|
||||
import com.miti99.loto.ui.theme.LotoTheme
|
||||
import com.miti99.loto.ui.toComposeColor
|
||||
|
||||
/**
|
||||
* Empty-state hero for master mode, ported from `MasterEmptyState.svelte`:
|
||||
* a ghost mock of the 11×9 tracking grid keeps the page silhouette
|
||||
* consistent with mid-game.
|
||||
*/
|
||||
@Composable
|
||||
fun MasterEmptyState(modifier: Modifier = Modifier) {
|
||||
val palette = LotoTheme.palette
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier.fillMaxWidth().padding(vertical = 40.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 320.dp)
|
||||
.alpha(0.3f)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.border(1.dp, palette.buttonSecondaryBorder, RoundedCornerShape(6.dp))
|
||||
.padding(bottom = 24.dp),
|
||||
) {
|
||||
for (rowStart in 0 until 99 step 9) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
for (i in rowStart until rowStart + 9) {
|
||||
val filled = i % 11 < 9 && i % 7 == 0
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.aspectRatio(1f)
|
||||
.background(
|
||||
if (filled) palette.cellBg
|
||||
else Settings.DEFAULT_EMPTY_CELL_COLOR.toComposeColor(),
|
||||
),
|
||||
) {
|
||||
if (filled) {
|
||||
Text(
|
||||
text = "${(i * 13) % 90 + 1}",
|
||||
fontSize = 8.sp,
|
||||
color = palette.cellText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.master_empty_badge).uppercase(),
|
||||
color = palette.masterHeading,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
letterSpacing = 0.1.em,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(palette.tokenBg)
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
)
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
append(stringResource(R.string.master_empty_hint_prefix))
|
||||
append(" ")
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
color = palette.masterHeading,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontStyle = FontStyle.Normal,
|
||||
),
|
||||
) {
|
||||
append("\"" + stringResource(R.string.master_new_game) + "\"")
|
||||
}
|
||||
append(" ")
|
||||
append(stringResource(R.string.master_empty_hint_suffix))
|
||||
},
|
||||
color = palette.subtitle,
|
||||
fontSize = 16.sp,
|
||||
fontStyle = FontStyle.Italic,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.master_empty_ready),
|
||||
color = palette.subtitle,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package com.miti99.loto.ui.master
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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 androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.miti99.loto.R
|
||||
import com.miti99.loto.settings.Settings
|
||||
import com.miti99.loto.state.MasterPanelViewModel
|
||||
import com.miti99.loto.ui.player.ConfirmDialog
|
||||
import com.miti99.loto.ui.rememberReducedMotion
|
||||
import com.miti99.loto.ui.theme.CondensedNumberFont
|
||||
import com.miti99.loto.ui.theme.LotoTheme
|
||||
import com.miti99.loto.ui.toComposeColor
|
||||
|
||||
/**
|
||||
* The host (quản trò) surface, ported from `MasterPanel.svelte`: controls,
|
||||
* countdown, current-number hero, called history in draw order, and the
|
||||
* 11×9 ones-digit tracking board (or the ghost empty state).
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun MasterPanelScreen(
|
||||
viewModel: MasterPanelViewModel,
|
||||
settings: Settings,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val palette = LotoTheme.palette
|
||||
val master by viewModel.masterState.collectAsState()
|
||||
val autoRunning by viewModel.autoRunning.collectAsState()
|
||||
val tickKey by viewModel.tickKey.collectAsState()
|
||||
val reducedMotion = rememberReducedMotion()
|
||||
var confirmNewGame by remember { mutableStateOf(false) }
|
||||
|
||||
val hasGame = master.called.isNotEmpty() || master.remaining.isNotEmpty()
|
||||
val lastCalled = master.called.lastOrNull()
|
||||
val heroRequester = remember { BringIntoViewRequester() }
|
||||
|
||||
// Keep the hero visible on each draw (the web scrolls it into view).
|
||||
LaunchedEffect(tickKey) {
|
||||
if (tickKey > 0 && lastCalled != null) heroRequester.bringIntoView()
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
// Controls
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
) {
|
||||
PillButton(
|
||||
text = stringResource(R.string.master_new_game),
|
||||
container = palette.masterNewGame,
|
||||
onClick = { if (hasGame) confirmNewGame = true else viewModel.newGame() },
|
||||
)
|
||||
if (hasGame && master.remaining.isNotEmpty()) {
|
||||
if (settings.autoCallEnabled) {
|
||||
PillButton(
|
||||
text = stringResource(
|
||||
if (autoRunning) R.string.master_auto_stop
|
||||
else R.string.master_auto_start,
|
||||
),
|
||||
container = if (autoRunning) palette.masterStop else palette.masterDraw,
|
||||
onClick = { viewModel.toggleAuto() },
|
||||
)
|
||||
} else {
|
||||
PillButton(
|
||||
text = stringResource(R.string.master_draw),
|
||||
container = palette.masterDraw,
|
||||
onClick = { viewModel.drawNext() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.autoCallEnabled && hasGame && master.remaining.isNotEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.master_auto_speed, settings.autoCallSpeed),
|
||||
color = palette.subtitle,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
if (autoRunning && master.remaining.isNotEmpty()) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
|
||||
) {
|
||||
AutoCountdown(
|
||||
running = autoRunning,
|
||||
durationSeconds = settings.autoCallSpeed,
|
||||
tickKey = tickKey,
|
||||
reducedMotion = reducedMotion,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Current-number hero
|
||||
if (lastCalled != null) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 20.dp)
|
||||
.bringIntoViewRequester(heroRequester),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.master_last_called).uppercase(),
|
||||
color = palette.subtitle,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
letterSpacing = 0.2.em,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(144.dp)
|
||||
.clip(CircleShape)
|
||||
.background(palette.tokenBg)
|
||||
.border(7.dp, palette.tokenBorder, CircleShape),
|
||||
) {
|
||||
Text(
|
||||
text = lastCalled.toString(),
|
||||
color = palette.tokenText,
|
||||
fontFamily = CondensedNumberFont,
|
||||
fontSize = 64.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.master_called_count,
|
||||
master.called.size,
|
||||
master.remaining.size,
|
||||
),
|
||||
color = palette.subtitle,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Called history (draw order)
|
||||
if (master.called.isNotEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.master_call_order),
|
||||
color = palette.subtitle,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.padding(bottom = 6.dp),
|
||||
)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 20.dp),
|
||||
) {
|
||||
for (num in master.called) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(CircleShape)
|
||||
.background(palette.tokenBg)
|
||||
.border(2.dp, palette.tokenBorder, CircleShape),
|
||||
) {
|
||||
Text(
|
||||
text = num.toString(),
|
||||
color = palette.tokenText,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasGame) {
|
||||
MasterBoard(
|
||||
called = master.called,
|
||||
lastCalled = lastCalled,
|
||||
emptyCellColor = settings.emptyCellColor.toComposeColor(),
|
||||
textScale = settings.boardTextScale,
|
||||
)
|
||||
} else {
|
||||
MasterEmptyState()
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmNewGame) {
|
||||
ConfirmDialog(
|
||||
message = stringResource(R.string.master_confirm_new_game),
|
||||
onConfirm = {
|
||||
confirmNewGame = false
|
||||
viewModel.newGame()
|
||||
},
|
||||
onDismiss = { confirmNewGame = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PillButton(text: String, container: Color, onClick: () -> Unit) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
shape = RoundedCornerShape(50),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = container, contentColor = Color.White),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 17.sp,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 11×9 ones-digit tracking board. Called numbers get the cream/sky token; the last draw wears a red ring. */
|
||||
@Composable
|
||||
private fun MasterBoard(
|
||||
called: List<Int>,
|
||||
lastCalled: Int?,
|
||||
emptyCellColor: Color,
|
||||
textScale: Float,
|
||||
) {
|
||||
val palette = LotoTheme.palette
|
||||
// number -> 1-based draw order for the corner badges.
|
||||
val callOrder = remember(called) { called.withIndex().associate { (i, n) -> n to i + 1 } }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.border(1.dp, palette.boardCellBorder, RoundedCornerShape(16.dp))
|
||||
.background(palette.boardBg),
|
||||
) {
|
||||
for (row in MasterBoardLayout.BOARD) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
for (num in row) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.aspectRatio(1f)
|
||||
.background(if (num > 0) palette.boardBg else emptyCellColor)
|
||||
.border(0.5.dp, palette.boardCellBorder),
|
||||
) {
|
||||
if (num > 0) {
|
||||
val order = callOrder[num]
|
||||
val isCalled = order != null
|
||||
val isLast = isCalled && num == lastCalled
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxSize(0.82f)
|
||||
.scale(if (isLast) 1.1f else 1f)
|
||||
.clip(CircleShape)
|
||||
.background(if (isCalled) palette.tokenBg else palette.tokenUncalledBg)
|
||||
.border(
|
||||
width = if (isLast) 3.dp else 2.dp,
|
||||
color = when {
|
||||
isLast -> palette.lastCalledRing
|
||||
isCalled -> palette.tokenBorder
|
||||
else -> palette.tokenUncalledBorder
|
||||
},
|
||||
shape = CircleShape,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = num.toString(),
|
||||
color = if (isCalled) palette.tokenText else palette.tokenUncalledText,
|
||||
fontSize = (14 * textScale).sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
if (isCalled) {
|
||||
Text(
|
||||
text = order.toString(),
|
||||
color = palette.subtitle,
|
||||
fontSize = 8.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = 1.dp, end = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.miti99.loto.ui.player
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
|
||||
/**
|
||||
* Falling-emoji burst for tier-2 celebrations, ported from the web's
|
||||
* `.confetti` keyframes: 12 particles, staggered delays, full-height fall
|
||||
* with rotation over 1.6s. One shared progress clock drives all particles —
|
||||
* no per-particle animators. Hội-chợ flavour set (lantern, bamboo,
|
||||
* chopsticks) so it reads as a Vietnamese fair, not a generic party.
|
||||
*/
|
||||
private val CONFETTI_EMOJI = listOf("🎊", "✨", "🎉", "🥳", "🥢", "🎋", "🏮")
|
||||
private const val PARTICLES = 12
|
||||
private const val FALL_MS = 1600
|
||||
private const val MAX_DELAY_MS = 400
|
||||
|
||||
@Composable
|
||||
fun ConfettiOverlay(modifier: Modifier = Modifier) {
|
||||
var size by remember { mutableStateOf(IntSize.Zero) }
|
||||
val clock = remember { Animatable(0f) }
|
||||
LaunchedEffect(Unit) {
|
||||
clock.animateTo(
|
||||
targetValue = 1f,
|
||||
animationSpec = tween(FALL_MS + MAX_DELAY_MS, easing = LinearEasing),
|
||||
)
|
||||
}
|
||||
Box(modifier = modifier.fillMaxSize().onSizeChanged { size = it }) {
|
||||
val elapsed = clock.value * (FALL_MS + MAX_DELAY_MS)
|
||||
for (i in 0 until PARTICLES) {
|
||||
// Same deterministic spread the web uses.
|
||||
val xFraction = ((i * 8.3f + (i % 3) * 11f) % 100f) / 100f
|
||||
val delay = (i * 37) % MAX_DELAY_MS
|
||||
val rotation = ((i * 67) % 360).toFloat()
|
||||
val sizeSp = 24 + ((i * 13) % 11) * 1.6f
|
||||
val t = ((elapsed - delay) / FALL_MS).coerceIn(0f, 1f)
|
||||
if (t <= 0f) continue
|
||||
Text(
|
||||
text = CONFETTI_EMOJI[i % CONFETTI_EMOJI.size],
|
||||
fontSize = sizeSp.sp,
|
||||
modifier = Modifier.graphicsLayer {
|
||||
translationX = xFraction * size.width
|
||||
translationY = (-0.1f + 1.2f * t) * size.height
|
||||
rotationZ = rotation * t
|
||||
alpha = when {
|
||||
t < 0.1f -> t / 0.1f
|
||||
else -> 0.85f
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.miti99.loto.ui.player
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.miti99.loto.R
|
||||
import com.miti99.loto.ui.theme.LotoTheme
|
||||
|
||||
/**
|
||||
* The "Kinh!" (row win) celebration modal, ported from PlayerBoard.svelte.
|
||||
* Back press and backdrop tap dismiss it (Dialog's onDismissRequest).
|
||||
*/
|
||||
@Composable
|
||||
fun KinhDialog(
|
||||
congratsRow: Int,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val palette = LotoTheme.palette
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(palette.dialogBg)
|
||||
.padding(32.dp),
|
||||
) {
|
||||
Text(text = "🎉", fontSize = 48.sp)
|
||||
Text(
|
||||
text = stringResource(R.string.kinh_title),
|
||||
color = palette.kinhTitle,
|
||||
fontSize = 36.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.kinh_row_prefix),
|
||||
color = palette.dialogText,
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
Text(
|
||||
text = congratsRow.toString(),
|
||||
color = palette.kinhRow,
|
||||
fontSize = 56.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.kinh_row_suffix),
|
||||
color = palette.dialogText,
|
||||
fontSize = 16.sp,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.kinh_shout),
|
||||
color = palette.subtitle,
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
shape = RoundedCornerShape(50),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = palette.buttonPrimary,
|
||||
contentColor = Color.White,
|
||||
),
|
||||
modifier = Modifier.padding(top = 24.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.kinh_dismiss),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package com.miti99.loto.ui.player
|
||||
|
||||
import android.view.HapticFeedbackConstants
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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 androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.miti99.loto.R
|
||||
import com.miti99.loto.settings.Settings
|
||||
import com.miti99.loto.state.PlayerBoardViewModel
|
||||
import com.miti99.loto.ui.rememberReducedMotion
|
||||
import com.miti99.loto.ui.toComposeColor
|
||||
import com.miti99.loto.ui.theme.LotoTheme
|
||||
|
||||
/** The player-facing screen: action buttons, the card (or empty state), the chờ toast, and the Kinh celebration. */
|
||||
@Composable
|
||||
fun PlayerBoardScreen(
|
||||
viewModel: PlayerBoardViewModel,
|
||||
settings: Settings,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val palette = LotoTheme.palette
|
||||
val view = LocalView.current
|
||||
val reducedMotion = rememberReducedMotion()
|
||||
var confirmGenerate by remember { mutableStateOf(false) }
|
||||
var confirmClear by remember { mutableStateOf(false) }
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
// Action buttons
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
if (state.grid != null) confirmGenerate = true else viewModel.generate()
|
||||
},
|
||||
shape = RoundedCornerShape(50),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = palette.buttonPrimary,
|
||||
contentColor = Color.White,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.player_new_card),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
if (state.grid != null) {
|
||||
Button(
|
||||
onClick = {
|
||||
if (state.crossed.flatten().any { it }) confirmClear = true
|
||||
else viewModel.clearMarks()
|
||||
},
|
||||
shape = RoundedCornerShape(50),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = palette.buttonSecondaryBg,
|
||||
contentColor = palette.buttonSecondaryText,
|
||||
),
|
||||
border = androidx.compose.foundation.BorderStroke(2.dp, palette.buttonSecondaryBorder),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.player_clear_marks),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val grid = state.grid
|
||||
if (grid != null) {
|
||||
Box {
|
||||
PlayerCardGrid(
|
||||
grid = grid,
|
||||
crossed = state.crossed,
|
||||
rowComplete = state.rowComplete,
|
||||
waitingCells = state.waitingCells,
|
||||
sectionWaiting = state.sectionWaiting,
|
||||
emptyCellColor = settings.emptyCellColor.toComposeColor(),
|
||||
textScale = settings.boardTextScale,
|
||||
reducedMotion = reducedMotion,
|
||||
onCellClick = { row, col ->
|
||||
if (!reducedMotion) {
|
||||
view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
|
||||
}
|
||||
viewModel.onCellClick(row, col)
|
||||
},
|
||||
)
|
||||
// Chờ toast — centered overlay chip, tap to dismiss.
|
||||
state.toast?.let { message ->
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
) {
|
||||
Text(
|
||||
text = message,
|
||||
color = Color.White,
|
||||
fontSize = 26.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(palette.toastBg)
|
||||
.clickable { viewModel.dismissToast() }
|
||||
.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
PlayerEmptyState()
|
||||
}
|
||||
}
|
||||
|
||||
if (state.showCongrats) {
|
||||
KinhDialog(congratsRow = state.congratsRow, onDismiss = { viewModel.dismissCongrats() })
|
||||
if (state.celebrationTier >= 2 && !reducedMotion) {
|
||||
ConfettiOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmGenerate) {
|
||||
ConfirmDialog(
|
||||
message = stringResource(R.string.player_confirm_new_card),
|
||||
onConfirm = {
|
||||
confirmGenerate = false
|
||||
viewModel.generate()
|
||||
},
|
||||
onDismiss = { confirmGenerate = false },
|
||||
)
|
||||
}
|
||||
if (confirmClear) {
|
||||
ConfirmDialog(
|
||||
message = stringResource(R.string.player_confirm_clear_marks),
|
||||
onConfirm = {
|
||||
confirmClear = false
|
||||
viewModel.clearMarks()
|
||||
},
|
||||
onDismiss = { confirmClear = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConfirmDialog(message: String, onConfirm: () -> Unit, onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
text = { Text(message) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) { Text(stringResource(R.string.dialog_confirm)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** No-card-yet hint with a faded preview mini-grid, mirroring the web copy. */
|
||||
@Composable
|
||||
private fun PlayerEmptyState() {
|
||||
val palette = LotoTheme.palette
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 40.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 320.dp)
|
||||
.alpha(0.3f)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.border(1.dp, palette.buttonSecondaryBorder, RoundedCornerShape(6.dp))
|
||||
.padding(bottom = 24.dp),
|
||||
) {
|
||||
for (rowStart in 0 until 27 step 9) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
for (i in rowStart until rowStart + 9) {
|
||||
val filled = i % 3 == 0
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.aspectRatio(1f)
|
||||
.background(
|
||||
if (filled) palette.cellBg
|
||||
else Settings.DEFAULT_EMPTY_CELL_COLOR.toComposeColor(),
|
||||
),
|
||||
) {
|
||||
if (filled) {
|
||||
Text(
|
||||
text = "${(i * 7) % 90 + 1}",
|
||||
fontSize = 9.sp,
|
||||
color = palette.cellText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
append(stringResource(R.string.player_empty_hint_prefix))
|
||||
append(" ")
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
color = palette.title,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontStyle = FontStyle.Normal,
|
||||
),
|
||||
) {
|
||||
append("\"" + stringResource(R.string.player_new_card) + "\"")
|
||||
}
|
||||
append(" ")
|
||||
append(stringResource(R.string.player_empty_hint_suffix))
|
||||
},
|
||||
color = palette.subtitle,
|
||||
fontSize = 16.sp,
|
||||
fontStyle = FontStyle.Italic,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.player_empty_wish),
|
||||
color = palette.subtitle,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package com.miti99.loto.ui.player
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.miti99.loto.R
|
||||
import com.miti99.loto.game.PlayerCard
|
||||
import com.miti99.loto.ui.theme.LotoTheme
|
||||
|
||||
/*
|
||||
* Visual parity notes (from web PlayerBoard.svelte + app.css):
|
||||
* - The sheet is 3 stacked 3×9 sections (Tân Tân physical card), each headed
|
||||
* by an italic uppercase label band in the sky-blue section accent over a
|
||||
* pale blue band, flanked by 2px rules; a bare band closes the sheet.
|
||||
* - 4px vertical accent bars frame the sheet left and right.
|
||||
* - Cells: white bg, slate half-alpha 1px borders, condensed bold digits;
|
||||
* crossed = pale red bg + red diagonal slash; winning row = pale sky bg +
|
||||
* sky slash; empty cells take the user's emptyCellColor (dimmed 15% in
|
||||
* dark mode).
|
||||
* - A waiting section's label band pulses an inset amber ring (2.4s); the
|
||||
* awaited cell pulses a 3px amber ring (1.6s).
|
||||
*/
|
||||
|
||||
private val sectionLabelIds =
|
||||
listOf(R.string.section_label_1, R.string.section_label_2, R.string.section_label_3)
|
||||
|
||||
@Composable
|
||||
fun PlayerCardGrid(
|
||||
grid: List<List<Int>>,
|
||||
crossed: List<List<Boolean>>,
|
||||
rowComplete: List<Boolean>,
|
||||
waitingCells: Set<PlayerCard.Cell>,
|
||||
sectionWaiting: List<Boolean>,
|
||||
emptyCellColor: Color,
|
||||
textScale: Float,
|
||||
reducedMotion: Boolean,
|
||||
onCellClick: (row: Int, col: Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val palette = LotoTheme.palette
|
||||
// IntrinsicSize.Min lets the framing accent bars match the sheet height.
|
||||
Row(modifier = modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
|
||||
VerticalAccentBar()
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
for ((sectionIdx, startRow) in listOf(0, 3, 6).withIndex()) {
|
||||
SectionLabel(
|
||||
text = androidx.compose.ui.res.stringResource(sectionLabelIds[sectionIdx]),
|
||||
waiting = sectionWaiting.getOrNull(sectionIdx) == true,
|
||||
reducedMotion = reducedMotion,
|
||||
)
|
||||
for (row in startRow until startRow + 3) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
for (col in 0 until 9) {
|
||||
PlayerCell(
|
||||
num = grid[row][col],
|
||||
isCrossed = crossed.getOrNull(row)?.getOrNull(col) == true,
|
||||
rowComplete = rowComplete.getOrNull(row) == true,
|
||||
isWaiting = waitingCells.contains(PlayerCard.Cell(row, col)),
|
||||
emptyCellColor = emptyCellColor,
|
||||
textScale = textScale,
|
||||
reducedMotion = reducedMotion,
|
||||
onClick = { onCellClick(row, col) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Decorative bottom band matching the label bands.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(16.dp)
|
||||
.background(palette.sectionBandBg),
|
||||
)
|
||||
}
|
||||
VerticalAccentBar()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VerticalAccentBar() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(4.dp)
|
||||
.fillMaxHeight()
|
||||
.background(LotoTheme.palette.sectionAccent),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionLabel(text: String, waiting: Boolean, reducedMotion: Boolean) {
|
||||
val palette = LotoTheme.palette
|
||||
// Waiting ring: inset amber ring breathing at 2.4s; static when reduced.
|
||||
val ringAlpha: Float = if (waiting && !reducedMotion) {
|
||||
val transition = rememberInfiniteTransition(label = "section-waiting")
|
||||
val alpha by transition.animateFloat(
|
||||
initialValue = 0.45f,
|
||||
targetValue = 0.85f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(1200, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "section-waiting-alpha",
|
||||
)
|
||||
alpha
|
||||
} else if (waiting) {
|
||||
0.6f
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(palette.sectionBandBg)
|
||||
.then(
|
||||
if (waiting) {
|
||||
Modifier.border(2.dp, palette.waitingRing.copy(alpha = ringAlpha))
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 7.dp),
|
||||
) {
|
||||
LabelRule(Modifier.weight(1f))
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
color = palette.sectionAccent,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontStyle = FontStyle.Italic,
|
||||
letterSpacing = 0.14.em,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
)
|
||||
LabelRule(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LabelRule(modifier: Modifier) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(2.dp)
|
||||
.background(LotoTheme.palette.sectionAccent.copy(alpha = 0.7f)),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.miti99.loto.ui.player
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.miti99.loto.ui.theme.CondensedNumberFont
|
||||
import com.miti99.loto.ui.theme.LotoTheme
|
||||
|
||||
/** Cell aspect ratio — the web card uses 3:4 (taller than wide) on phones. */
|
||||
private const val CELL_ASPECT = 3f / 4f
|
||||
|
||||
/**
|
||||
* One cell of the 9×9 player card. Number cells toggle a diagonal cross
|
||||
* (red while the row is in progress, sky blue once the row wins); the cell
|
||||
* holding a waiting row's awaited number pulses an amber inset ring.
|
||||
*/
|
||||
@Composable
|
||||
fun PlayerCell(
|
||||
num: Int,
|
||||
isCrossed: Boolean,
|
||||
rowComplete: Boolean,
|
||||
isWaiting: Boolean,
|
||||
emptyCellColor: Color,
|
||||
textScale: Float,
|
||||
reducedMotion: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val palette = LotoTheme.palette
|
||||
if (num <= 0) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.aspectRatio(CELL_ASPECT)
|
||||
.background(emptyCellColor)
|
||||
.background(palette.emptyCellDim)
|
||||
.border(1.dp, palette.cellBorder),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val background = when {
|
||||
isCrossed && rowComplete -> palette.cellWinBg
|
||||
isCrossed -> palette.cellCrossedBg
|
||||
else -> palette.cellBg
|
||||
}
|
||||
val textColor = when {
|
||||
isCrossed && rowComplete -> palette.cellWinText
|
||||
isCrossed -> palette.cellCrossedText
|
||||
else -> palette.cellText
|
||||
}
|
||||
val crossColor = if (rowComplete) palette.crossWinStroke else palette.crossStroke
|
||||
|
||||
// Waiting pulse — 1.6s breathing ring, static at 0.7 alpha under
|
||||
// reduced motion (mirrors the web's cell-waiting keyframes).
|
||||
val ringAlpha: Float = if (isWaiting && !reducedMotion) {
|
||||
val transition = rememberInfiniteTransition(label = "cell-waiting")
|
||||
val alpha by transition.animateFloat(
|
||||
initialValue = 0.45f,
|
||||
targetValue = 0.95f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(800, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "cell-waiting-alpha",
|
||||
)
|
||||
alpha
|
||||
} else if (isWaiting) {
|
||||
0.7f
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier
|
||||
.aspectRatio(CELL_ASPECT)
|
||||
.background(background)
|
||||
.border(1.dp, palette.cellBorder)
|
||||
.drawBehind {
|
||||
if (isWaiting) {
|
||||
drawRect(
|
||||
color = palette.waitingRing.copy(alpha = ringAlpha),
|
||||
style = androidx.compose.ui.graphics.drawscope.Stroke(
|
||||
width = 3.dp.toPx() * 2, // inset stroke: half clips outside
|
||||
),
|
||||
)
|
||||
}
|
||||
if (isCrossed) {
|
||||
val inset = 6.dp.toPx()
|
||||
drawLine(
|
||||
color = crossColor,
|
||||
start = Offset(inset, inset),
|
||||
end = Offset(size.width - inset, size.height - inset),
|
||||
strokeWidth = 3.dp.toPx() / 2,
|
||||
cap = StrokeCap.Round,
|
||||
)
|
||||
}
|
||||
}
|
||||
.clickable(onClick = onClick)
|
||||
.semantics {
|
||||
stateDescription = buildString {
|
||||
append("Số $num")
|
||||
if (isCrossed) append(", đã đánh dấu")
|
||||
if (isWaiting) append(", đang chờ")
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = num.toString(),
|
||||
color = textColor,
|
||||
fontFamily = CondensedNumberFont,
|
||||
fontSize = (20 * textScale).sp,
|
||||
letterSpacing = (-0.04).em,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package com.miti99.loto.ui.settings
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Text
|
||||
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 androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.miti99.loto.R
|
||||
import com.miti99.loto.ui.theme.LotoTheme
|
||||
import com.miti99.loto.ui.toComposeColor
|
||||
|
||||
/** Office "Standard Colors" palette (10 swatches), same as the web. Default is Purple. */
|
||||
private val PRESETS = listOf(
|
||||
"#C00000", "#FF0000", "#FFC000", "#FFFF00", "#92D050",
|
||||
"#00B050", "#00B0F0", "#0070C0", "#002060", "#7030A0",
|
||||
)
|
||||
|
||||
/**
|
||||
* Empty-cell color control: RGB sliders as the custom picker (the native
|
||||
* stand-in for the web's `<input type=color>`) plus the Office preset
|
||||
* swatches the web ships.
|
||||
*/
|
||||
@Composable
|
||||
fun EmptyCellColorPicker(
|
||||
value: String,
|
||||
onPick: (String) -> Unit,
|
||||
) {
|
||||
val palette = LotoTheme.palette
|
||||
// The sliders preview through local state and commit once on drag end —
|
||||
// per-frame commits would rewrite the preferences file ~250× per drag.
|
||||
var draft by remember { mutableStateOf<String?>(null) }
|
||||
val shown = draft ?: value
|
||||
val color = shown.toComposeColor()
|
||||
val r = (color.red * 255).toInt()
|
||||
val g = (color.green * 255).toInt()
|
||||
val b = (color.blue * 255).toInt()
|
||||
val commitDraft: () -> Unit = {
|
||||
draft?.let(onPick)
|
||||
draft = null
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.border(1.dp, palette.buttonSecondaryBorder, RoundedCornerShape(12.dp))
|
||||
.padding(12.dp),
|
||||
) {
|
||||
SectionCaption(stringResource(R.string.settings_empty_color_custom))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp, 40.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(color)
|
||||
.border(1.dp, palette.buttonSecondaryBorder, RoundedCornerShape(8.dp)),
|
||||
)
|
||||
Text(
|
||||
text = shown.uppercase(),
|
||||
color = palette.buttonSecondaryText,
|
||||
fontSize = 14.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
)
|
||||
}
|
||||
ChannelSlider("R", r, onChange = { draft = hex(it, g, b) }, onChangeFinished = commitDraft)
|
||||
ChannelSlider("G", g, onChange = { draft = hex(r, it, b) }, onChangeFinished = commitDraft)
|
||||
ChannelSlider("B", b, onChange = { draft = hex(r, g, it) }, onChangeFinished = commitDraft)
|
||||
|
||||
SectionCaption(
|
||||
stringResource(R.string.settings_empty_color_presets),
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
for (hexValue in PRESETS.take(5)) {
|
||||
PresetSwatch(hexValue, value, onPick, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
) {
|
||||
for (hexValue in PRESETS.drop(5)) {
|
||||
PresetSwatch(hexValue, value, onPick, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionCaption(text: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
color = LotoTheme.palette.subtitle,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
letterSpacing = 0.08.em,
|
||||
modifier = modifier.padding(bottom = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelSlider(
|
||||
label: String,
|
||||
channel: Int,
|
||||
onChange: (Int) -> Unit,
|
||||
onChangeFinished: () -> Unit,
|
||||
) {
|
||||
val palette = LotoTheme.palette
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = label,
|
||||
color = palette.subtitle,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
Slider(
|
||||
value = channel.toFloat(),
|
||||
onValueChange = { onChange(it.toInt().coerceIn(0, 255)) },
|
||||
onValueChangeFinished = onChangeFinished,
|
||||
valueRange = 0f..255f,
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = palette.title,
|
||||
activeTrackColor = palette.title,
|
||||
),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = channel.toString(),
|
||||
color = palette.subtitle,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PresetSwatch(
|
||||
hexValue: String,
|
||||
selectedValue: String,
|
||||
onPick: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val palette = LotoTheme.palette
|
||||
val selected = selectedValue.equals(hexValue, ignoreCase = true)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(hexValue.toComposeColor())
|
||||
.border(
|
||||
width = if (selected) 3.dp else 2.dp,
|
||||
color = if (selected) palette.title else palette.buttonSecondaryBorder,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
)
|
||||
.clickable { onPick(hexValue) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun hex(r: Int, g: Int, b: Int): String =
|
||||
"#%02X%02X%02X".format(r, g, b)
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.miti99.loto.ui.settings
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.miti99.loto.ui.theme.LotoTheme
|
||||
|
||||
/**
|
||||
* Shared controls of the settings sheet, styled after the web's option
|
||||
* buttons and full-row switches (rose selected state, slate borders).
|
||||
*/
|
||||
|
||||
@Composable
|
||||
fun ChoiceButton(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val palette = LotoTheme.palette
|
||||
val border = if (selected) palette.title else palette.buttonSecondaryBorder
|
||||
val text = if (selected) palette.title else palette.buttonSecondaryText
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.border(2.dp, border, RoundedCornerShape(8.dp))
|
||||
.background(if (selected) palette.title.copy(alpha = 0.08f) else Color.Transparent)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 8.dp, vertical = 10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = text,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Whole row toggles — tapping the label flips the switch too (web parity). */
|
||||
@Composable
|
||||
fun SwitchRow(
|
||||
label: String,
|
||||
checked: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val palette = LotoTheme.palette
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.border(2.dp, palette.buttonSecondaryBorder, RoundedCornerShape(8.dp))
|
||||
.clickable(onClick = onToggle)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = palette.buttonSecondaryText,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// Compact pill switch matching the web's 40×24 track.
|
||||
Box(
|
||||
contentAlignment = if (checked) Alignment.CenterEnd else Alignment.CenterStart,
|
||||
modifier = Modifier
|
||||
.size(width = 40.dp, height = 24.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(if (checked) LotoTheme.palette.crossWinStroke else palette.buttonSecondaryBorder),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(2.dp)
|
||||
.size(20.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package com.miti99.loto.ui.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Text
|
||||
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 androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.miti99.loto.R
|
||||
import com.miti99.loto.settings.AppMode
|
||||
import com.miti99.loto.settings.Settings
|
||||
import com.miti99.loto.settings.ThemeSetting
|
||||
import com.miti99.loto.state.SettingsViewModel
|
||||
import com.miti99.loto.ui.player.ConfirmDialog
|
||||
import com.miti99.loto.ui.theme.LotoTheme
|
||||
|
||||
/**
|
||||
* The settings sheet, ported control-for-control from the web's
|
||||
* `SettingsButton.svelte` dialog: theme, board text size, display mode,
|
||||
* auto-call, sound/voice, empty-cell color, reset/done. Every change
|
||||
* applies immediately — no save button.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsSheet(
|
||||
viewModel: SettingsViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val palette = LotoTheme.palette
|
||||
val settings by viewModel.settings.collectAsState()
|
||||
var confirmReset by remember { mutableStateOf(false) }
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
containerColor = palette.dialogBg,
|
||||
// The sheet is tall (7 sections); open fully expanded like the web's
|
||||
// full-height settings modal instead of a half-open peek.
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_title),
|
||||
color = palette.foreground,
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Hint(stringResource(R.string.settings_subtitle))
|
||||
|
||||
// Theme
|
||||
SectionTitle(stringResource(R.string.settings_theme))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
for ((theme, labelId) in listOf(
|
||||
ThemeSetting.AUTO to R.string.settings_theme_auto,
|
||||
ThemeSetting.LIGHT to R.string.settings_theme_light,
|
||||
ThemeSetting.DARK to R.string.settings_theme_dark,
|
||||
)) {
|
||||
ChoiceButton(
|
||||
label = stringResource(labelId),
|
||||
selected = settings.theme == theme,
|
||||
onClick = { viewModel.setTheme(theme) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Board text size
|
||||
SectionTitle(stringResource(R.string.settings_text_scale))
|
||||
Hint(stringResource(R.string.settings_text_scale_hint))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
val labels = listOf(
|
||||
R.string.settings_text_scale_s,
|
||||
R.string.settings_text_scale_m,
|
||||
R.string.settings_text_scale_l,
|
||||
R.string.settings_text_scale_xl,
|
||||
)
|
||||
Settings.BOARD_TEXT_SCALES.forEachIndexed { i, scale ->
|
||||
ChoiceButton(
|
||||
label = stringResource(labels[i]),
|
||||
selected = settings.boardTextScale == scale,
|
||||
onClick = { viewModel.setBoardTextScale(scale) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Display mode
|
||||
SectionTitle(stringResource(R.string.settings_mode))
|
||||
Hint(stringResource(R.string.settings_mode_hint))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
for ((mode, labelId) in listOf(
|
||||
AppMode.PLAYER to R.string.settings_mode_player,
|
||||
AppMode.MASTER to R.string.settings_mode_master,
|
||||
AppMode.BOTH to R.string.settings_mode_both,
|
||||
)) {
|
||||
ChoiceButton(
|
||||
label = stringResource(labelId),
|
||||
selected = settings.mode == mode,
|
||||
onClick = { viewModel.setMode(mode) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stringResource(
|
||||
when (settings.mode) {
|
||||
AppMode.PLAYER -> R.string.settings_mode_hint_player
|
||||
AppMode.MASTER -> R.string.settings_mode_hint_master
|
||||
AppMode.BOTH -> R.string.settings_mode_hint_both
|
||||
},
|
||||
),
|
||||
color = palette.subtitle,
|
||||
fontSize = 12.sp,
|
||||
fontStyle = FontStyle.Italic,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
|
||||
// Auto-call (only relevant when the master panel is visible)
|
||||
if (settings.mode != AppMode.PLAYER) {
|
||||
SectionTitle(stringResource(R.string.settings_auto_call))
|
||||
Hint(stringResource(R.string.settings_auto_call_hint))
|
||||
SwitchRow(
|
||||
label = stringResource(R.string.settings_auto_call_switch),
|
||||
checked = settings.autoCallEnabled,
|
||||
onToggle = { viewModel.setAutoCallEnabled(!settings.autoCallEnabled) },
|
||||
)
|
||||
if (settings.autoCallEnabled) {
|
||||
Column(modifier = Modifier.padding(start = 12.dp, top = 8.dp)) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.settings_auto_call_speed,
|
||||
settings.autoCallSpeed,
|
||||
),
|
||||
color = palette.buttonSecondaryText,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
Slider(
|
||||
value = settings.autoCallSpeed.toFloat(),
|
||||
onValueChange = {
|
||||
viewModel.setAutoCallSpeed(it.toInt().coerceIn(1, 10))
|
||||
},
|
||||
valueRange = 1f..10f,
|
||||
steps = 8,
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = palette.title,
|
||||
activeTrackColor = palette.title,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sound / voice
|
||||
SectionTitle(stringResource(R.string.settings_sound))
|
||||
Hint(stringResource(R.string.settings_sound_hint))
|
||||
if (settings.mode != AppMode.PLAYER) {
|
||||
SwitchRow(
|
||||
label = stringResource(R.string.settings_voice_master),
|
||||
checked = settings.voiceEnabledMaster,
|
||||
onToggle = { viewModel.setVoiceEnabledMaster(!settings.voiceEnabledMaster) },
|
||||
)
|
||||
Hint(
|
||||
stringResource(
|
||||
if (settings.mode == AppMode.BOTH) R.string.settings_voice_master_hint_both
|
||||
else R.string.settings_voice_master_hint,
|
||||
),
|
||||
small = true,
|
||||
)
|
||||
}
|
||||
if (settings.mode != AppMode.MASTER) {
|
||||
SwitchRow(
|
||||
label = stringResource(R.string.settings_voice_player),
|
||||
checked = settings.voiceEnabledPlayer,
|
||||
onToggle = { viewModel.setVoiceEnabledPlayer(!settings.voiceEnabledPlayer) },
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
if (settings.voiceEnabledPlayer && settings.mode != AppMode.BOTH) {
|
||||
Column(modifier = Modifier.padding(start = 12.dp, top = 8.dp)) {
|
||||
SwitchRow(
|
||||
label = stringResource(R.string.settings_voice_waiting),
|
||||
checked = settings.voiceWaitingNumber,
|
||||
onToggle = {
|
||||
viewModel.setVoiceWaitingNumber(!settings.voiceWaitingNumber)
|
||||
},
|
||||
)
|
||||
Hint(stringResource(R.string.settings_voice_waiting_hint), small = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (viewModel.voices.size > 1) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_voice_picker),
|
||||
color = palette.buttonSecondaryText,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 6.dp),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
for (voice in viewModel.voices) {
|
||||
ChoiceButton(
|
||||
label = voice.label,
|
||||
selected = settings.voice == voice.id,
|
||||
onClick = { viewModel.setVoice(voice.id) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty-cell color
|
||||
SectionTitle(stringResource(R.string.settings_empty_color))
|
||||
EmptyCellColorPicker(
|
||||
value = settings.emptyCellColor,
|
||||
onPick = { viewModel.setEmptyCellColor(it) },
|
||||
)
|
||||
|
||||
// Footer actions
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 20.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { confirmReset = true },
|
||||
shape = RoundedCornerShape(50),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_reset),
|
||||
color = palette.buttonSecondaryText,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
shape = RoundedCornerShape(50),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = palette.buttonPrimary,
|
||||
contentColor = Color.White,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_done),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmReset) {
|
||||
ConfirmDialog(
|
||||
message = stringResource(R.string.settings_reset_confirm),
|
||||
onConfirm = {
|
||||
confirmReset = false
|
||||
viewModel.reset()
|
||||
},
|
||||
onDismiss = { confirmReset = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionTitle(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
color = LotoTheme.palette.foreground,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(top = 20.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Hint(text: String, small: Boolean = false) {
|
||||
Text(
|
||||
text = text,
|
||||
color = LotoTheme.palette.subtitle,
|
||||
fontSize = if (small) 12.sp else 14.sp,
|
||||
modifier = Modifier.padding(bottom = if (small) 0.dp else 8.dp, top = if (small) 6.dp else 0.dp),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package com.miti99.loto.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.DeviceFontFamilyName
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import com.miti99.loto.settings.ThemeSetting
|
||||
|
||||
/**
|
||||
* Palette lifted from the web app (`web/src/app.css` + the Tailwind classes
|
||||
* used in its components) so the native board reads identically. Light =
|
||||
* warm cream festive; dark = near-black navy.
|
||||
*/
|
||||
data class LotoPalette(
|
||||
val background: Color,
|
||||
val foreground: Color,
|
||||
/** Sky-blue brand accent for section bands and frames. */
|
||||
val sectionAccent: Color,
|
||||
val sectionBandBg: Color,
|
||||
/** Page title + primary action (web rose-600 / rose-400). */
|
||||
val title: Color,
|
||||
val subtitle: Color,
|
||||
val buttonPrimary: Color,
|
||||
val buttonSecondaryBg: Color,
|
||||
val buttonSecondaryText: Color,
|
||||
val buttonSecondaryBorder: Color,
|
||||
// Player card cells
|
||||
val cellBorder: Color,
|
||||
val cellBg: Color,
|
||||
val cellText: Color,
|
||||
val cellCrossedBg: Color,
|
||||
val cellCrossedText: Color,
|
||||
val cellWinBg: Color,
|
||||
val cellWinText: Color,
|
||||
val crossStroke: Color,
|
||||
val crossWinStroke: Color,
|
||||
val waitingRing: Color,
|
||||
val toastBg: Color,
|
||||
// Kinh dialog
|
||||
val dialogBg: Color,
|
||||
val dialogText: Color,
|
||||
val kinhTitle: Color,
|
||||
val kinhRow: Color,
|
||||
// Master panel
|
||||
val masterHeading: Color,
|
||||
val masterNewGame: Color,
|
||||
val masterDraw: Color,
|
||||
val masterStop: Color,
|
||||
val tokenBg: Color,
|
||||
val tokenBorder: Color,
|
||||
val tokenText: Color,
|
||||
val tokenUncalledBorder: Color,
|
||||
val tokenUncalledBg: Color,
|
||||
val tokenUncalledText: Color,
|
||||
val lastCalledRing: Color,
|
||||
val boardBg: Color,
|
||||
val boardCellBorder: Color,
|
||||
val countdownTrack: Color,
|
||||
val countdownArc: Color,
|
||||
/** Dim overlay drawn over empty cells in dark mode (web: black/15). */
|
||||
val emptyCellDim: Color,
|
||||
)
|
||||
|
||||
private val Light = LotoPalette(
|
||||
background = Color(0xFFFFFBEB),
|
||||
foreground = Color(0xFF1E293B),
|
||||
sectionAccent = Color(0xFF1565C0),
|
||||
sectionBandBg = Color(0xC7E8F0FA),
|
||||
title = Color(0xFFE11D48),
|
||||
subtitle = Color(0xFF475569),
|
||||
buttonPrimary = Color(0xFFE11D48),
|
||||
buttonSecondaryBg = Color.White,
|
||||
buttonSecondaryText = Color(0xFF334155),
|
||||
buttonSecondaryBorder = Color(0xFFCBD5E1),
|
||||
cellBorder = Color(0x8094A3B8),
|
||||
cellBg = Color.White,
|
||||
cellText = Color.Black,
|
||||
cellCrossedBg = Color(0xFFFEF2F2),
|
||||
cellCrossedText = Color(0xFFB91C1C),
|
||||
cellWinBg = Color(0xFFE0F2FE),
|
||||
cellWinText = Color(0xFF0369A1),
|
||||
crossStroke = Color(0xFFEF4444),
|
||||
crossWinStroke = Color(0xFF0284C7),
|
||||
waitingRing = Color(0xFFF59E0B),
|
||||
toastBg = Color(0xBFF59E0B),
|
||||
dialogBg = Color.White,
|
||||
dialogText = Color(0xFF334155),
|
||||
kinhTitle = Color(0xFFD97706),
|
||||
kinhRow = Color(0xFFE11D48),
|
||||
masterHeading = Color(0xFFB45309),
|
||||
masterNewGame = Color(0xFFD97706),
|
||||
masterDraw = Color(0xFF0369A1),
|
||||
masterStop = Color(0xFFE11D48),
|
||||
tokenBg = Color(0xFFFFFBEB),
|
||||
tokenBorder = Color(0xFF0284C7),
|
||||
tokenText = Color(0xFF0369A1),
|
||||
tokenUncalledBorder = Color(0xFFCBD5E1),
|
||||
tokenUncalledBg = Color(0x66F8FAFC),
|
||||
tokenUncalledText = Color(0xFF94A3B8),
|
||||
lastCalledRing = Color(0xFFEF4444),
|
||||
boardBg = Color.White,
|
||||
boardCellBorder = Color(0xCCE2E8F0),
|
||||
countdownTrack = Color(0xFFE2E8F0),
|
||||
countdownArc = Color(0xFFF59E0B),
|
||||
emptyCellDim = Color.Transparent,
|
||||
)
|
||||
|
||||
private val Dark = LotoPalette(
|
||||
background = Color(0xFF050813),
|
||||
foreground = Color(0xFFE2E8F0),
|
||||
sectionAccent = Color(0xFF64B5F6),
|
||||
sectionBandBg = Color(0x660D47A1),
|
||||
title = Color(0xFFFB7185),
|
||||
subtitle = Color(0xFFCBD5E1),
|
||||
buttonPrimary = Color(0xFFE11D48),
|
||||
buttonSecondaryBg = Color(0xFF1E293B),
|
||||
buttonSecondaryText = Color(0xFFE2E8F0),
|
||||
buttonSecondaryBorder = Color(0xFF475569),
|
||||
cellBorder = Color(0x66475569),
|
||||
cellBg = Color(0xFF1E293B),
|
||||
cellText = Color(0xFFF1F5F9),
|
||||
cellCrossedBg = Color(0x66450A0A),
|
||||
cellCrossedText = Color(0xFFFCA5A5),
|
||||
cellWinBg = Color(0x990C4A6E),
|
||||
cellWinText = Color(0xFFBAE6FD),
|
||||
crossStroke = Color(0xFFEF4444),
|
||||
crossWinStroke = Color(0xFF0284C7),
|
||||
waitingRing = Color(0xFFF59E0B),
|
||||
toastBg = Color(0xBFD97706),
|
||||
dialogBg = Color(0xFF1E293B),
|
||||
dialogText = Color(0xFFE2E8F0),
|
||||
kinhTitle = Color(0xFFFBBF24),
|
||||
kinhRow = Color(0xFFFB7185),
|
||||
masterHeading = Color(0xFFFBBF24),
|
||||
masterNewGame = Color(0xFFD97706),
|
||||
masterDraw = Color(0xFF0369A1),
|
||||
masterStop = Color(0xFFE11D48),
|
||||
tokenBg = Color(0xFFFEF3C7),
|
||||
tokenBorder = Color(0xFF38BDF8),
|
||||
tokenText = Color(0xFF0369A1),
|
||||
tokenUncalledBorder = Color(0xFF475569),
|
||||
tokenUncalledBg = Color(0x4D334155),
|
||||
tokenUncalledText = Color(0xFF64748B),
|
||||
lastCalledRing = Color(0xFFF87171),
|
||||
boardBg = Color(0xFF1E293B),
|
||||
boardCellBorder = Color(0x99334155),
|
||||
countdownTrack = Color(0xFF334155),
|
||||
countdownArc = Color(0xFFFBBF24),
|
||||
emptyCellDim = Color(0x26000000),
|
||||
)
|
||||
|
||||
val LocalLotoPalette = staticCompositionLocalOf { Light }
|
||||
|
||||
/**
|
||||
* Condensed heavy digits — the Tân Tân card look (web `.tan-tan-num`, which
|
||||
* self-hosts Roboto Condensed Bold; Android ships it as the system
|
||||
* sans-serif-condensed family).
|
||||
*/
|
||||
val CondensedNumberFont = FontFamily(
|
||||
Font(DeviceFontFamilyName("sans-serif-condensed"), weight = FontWeight.Bold),
|
||||
)
|
||||
|
||||
object LotoTheme {
|
||||
val palette: LotoPalette
|
||||
@Composable @ReadOnlyComposable get() = LocalLotoPalette.current
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LotoTheme(
|
||||
theme: ThemeSetting,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val dark = when (theme) {
|
||||
ThemeSetting.AUTO -> isSystemInDarkTheme()
|
||||
ThemeSetting.LIGHT -> false
|
||||
ThemeSetting.DARK -> true
|
||||
}
|
||||
val palette = if (dark) Dark else Light
|
||||
val colorScheme = if (dark) {
|
||||
darkColorScheme(
|
||||
primary = palette.buttonPrimary,
|
||||
background = palette.background,
|
||||
surface = palette.dialogBg,
|
||||
onBackground = palette.foreground,
|
||||
onSurface = palette.foreground,
|
||||
)
|
||||
} else {
|
||||
lightColorScheme(
|
||||
primary = palette.buttonPrimary,
|
||||
background = palette.background,
|
||||
surface = palette.dialogBg,
|
||||
onBackground = palette.foreground,
|
||||
onSurface = palette.foreground,
|
||||
)
|
||||
}
|
||||
CompositionLocalProvider(LocalLotoPalette provides palette) {
|
||||
MaterialTheme(colorScheme = colorScheme, content = content)
|
||||
}
|
||||
}
|
||||
|
||||
/** Weight used by all board digits. */
|
||||
val BoardDigitWeight = FontWeight.Bold
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Dark variant of the shell theme; splash_background resolves to the
|
||||
near-black navy from values-night/colors.xml. -->
|
||||
<style name="Theme.Loto" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:windowBackground">@color/splash_background</item>
|
||||
</style>
|
||||
</resources>
|
||||
File renamed without changes.
@@ -0,0 +1,104 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Lô tô</string>
|
||||
<string name="app_subtitle">🏮 Hội chợ TN1</string>
|
||||
|
||||
<!-- Player board -->
|
||||
<string name="player_new_card">Tạo bảng mới</string>
|
||||
<string name="player_clear_marks">Xoá đánh dấu</string>
|
||||
<string name="player_confirm_new_card">Bạn có muốn tạo lại bảng không?</string>
|
||||
<string name="player_confirm_clear_marks">Bạn có muốn xoá tất cả đánh dấu không?</string>
|
||||
<string name="player_empty_hint_prefix">Nhấn</string>
|
||||
<string name="player_empty_hint_suffix">để bắt đầu chơi</string>
|
||||
<string name="player_empty_wish">🎫 Chúc cả nhà một ván vui vẻ</string>
|
||||
<string name="player_board_label">Bảng lô tô</string>
|
||||
<string name="toast_waiting">Chờ %1$d</string>
|
||||
<string name="toast_dismiss">Đóng thông báo</string>
|
||||
|
||||
<!-- Section bands (Tân Tân physical card) -->
|
||||
<string name="section_label_1">Lô tô</string>
|
||||
<string name="section_label_2">TN1 (2014–2017)</string>
|
||||
<string name="section_label_3">Độc-Đỉnh-Điên</string>
|
||||
|
||||
<!-- Kinh celebration -->
|
||||
<string name="kinh_title">Kinh!</string>
|
||||
<string name="kinh_row_prefix">Hàng</string>
|
||||
<string name="kinh_row_suffix">đã đầy đủ!</string>
|
||||
<string name="kinh_shout">Hãy hô to "Kinh!" 🎶</string>
|
||||
<string name="kinh_dismiss">Tuyệt vời! 🥳</string>
|
||||
<string name="kinh_close">Đóng</string>
|
||||
|
||||
<!-- Master panel -->
|
||||
<string name="master_heading">Quản trò</string>
|
||||
<string name="master_new_game">Ván mới</string>
|
||||
<string name="master_confirm_new_game">Bạn có muốn tạo ván mới không?</string>
|
||||
<string name="master_draw">Xổ số</string>
|
||||
<string name="master_auto_start">Bắt đầu</string>
|
||||
<string name="master_auto_stop">Dừng</string>
|
||||
<string name="master_auto_speed">Tự động: %1$ds/số</string>
|
||||
<string name="master_last_called">Số vừa xổ</string>
|
||||
<string name="master_called_count">Đã xổ: %1$d/90 · Còn lại: %2$d</string>
|
||||
<string name="master_call_order">Thứ tự đã xổ:</string>
|
||||
<string name="master_board_label">Bảng theo dõi số đã xổ</string>
|
||||
<string name="master_countdown">Đếm ngược: %1$d giây</string>
|
||||
<string name="master_empty_badge">Chế độ Quản trò</string>
|
||||
<string name="master_empty_hint_prefix">Nhấn</string>
|
||||
<string name="master_empty_hint_suffix">để bắt đầu xổ số</string>
|
||||
<string name="master_empty_ready">🎤 Đã sẵn sàng</string>
|
||||
|
||||
<!-- Footer -->
|
||||
<string name="footer_inspiration">Lấy cảm hứng từ những buổi họp lớp thiếu bộ lô tô của TN1 (2014–2017)</string>
|
||||
<string name="footer_credit">Made by miti99 with ❤️</string>
|
||||
|
||||
<!-- Shared dialogs -->
|
||||
<string name="dialog_confirm">Đồng ý</string>
|
||||
<string name="dialog_cancel">Hủy</string>
|
||||
|
||||
<!-- Settings sheet -->
|
||||
<string name="settings_open">Cài đặt</string>
|
||||
<string name="settings_title">Cài đặt</string>
|
||||
<string name="settings_subtitle">Tuỳ chỉnh giao diện bảng lô tô</string>
|
||||
<string name="settings_theme">Giao diện</string>
|
||||
<string name="settings_theme_auto">Tự động</string>
|
||||
<string name="settings_theme_light">Sáng</string>
|
||||
<string name="settings_theme_dark">Tối</string>
|
||||
<string name="settings_text_scale">Cỡ chữ bảng</string>
|
||||
<string name="settings_text_scale_hint">Cỡ số trên bảng người chơi và bảng quản trò</string>
|
||||
<string name="settings_text_scale_s">Nhỏ</string>
|
||||
<string name="settings_text_scale_m">Vừa</string>
|
||||
<string name="settings_text_scale_l">Lớn</string>
|
||||
<string name="settings_text_scale_xl">Rất lớn</string>
|
||||
<string name="settings_mode">Chế độ hiển thị</string>
|
||||
<string name="settings_mode_hint">Chọn vai trò để chỉ hiện phần liên quan</string>
|
||||
<string name="settings_mode_player">Người chơi</string>
|
||||
<string name="settings_mode_master">Quản trò</string>
|
||||
<string name="settings_mode_both">Cả hai</string>
|
||||
<string name="settings_mode_hint_player">Chỉ bảng người chơi</string>
|
||||
<string name="settings_mode_hint_master">Chỉ bảng quản trò (xổ số, theo dõi)</string>
|
||||
<string name="settings_mode_hint_both">Cả hai bảng — quản trò tự đánh dấu cho người chơi</string>
|
||||
<string name="settings_auto_call">Tự động xổ</string>
|
||||
<string name="settings_auto_call_hint">Tự xổ số liên tiếp khi đang ở chế độ quản trò</string>
|
||||
<string name="settings_auto_call_switch">Tự xổ số</string>
|
||||
<string name="settings_auto_call_speed">Tốc độ: %1$d giây/số</string>
|
||||
<string name="settings_sound">Âm thanh</string>
|
||||
<string name="settings_sound_hint">Đọc số bằng tiếng Việt</string>
|
||||
<string name="settings_voice_master">Quản trò đọc số</string>
|
||||
<string name="settings_voice_master_hint_both">Đọc số đã xổ + báo Chờ/Kinh.</string>
|
||||
<string name="settings_voice_master_hint">Đọc số đã xổ.</string>
|
||||
<string name="settings_voice_player">Báo Chờ / Kinh</string>
|
||||
<string name="settings_voice_waiting">Đọc thêm số đang chờ</string>
|
||||
<string name="settings_voice_waiting_hint">Tắt: chỉ nói "Chờ". Bật: nói "Chờ {số}".</string>
|
||||
<string name="settings_voice_picker">Giọng đọc</string>
|
||||
<string name="settings_empty_color">Màu ô trống</string>
|
||||
<string name="settings_empty_color_custom">Tuỳ chỉnh</string>
|
||||
<string name="settings_empty_color_presets">Mẫu sẵn</string>
|
||||
<string name="settings_reset">Đặt lại</string>
|
||||
<string name="settings_reset_confirm">Đặt lại tất cả cài đặt về mặc định?</string>
|
||||
<string name="settings_done">Xong</string>
|
||||
|
||||
<!-- Exit confirmation (back press with nothing to close) -->
|
||||
<string name="exit_title">Thoát Lô tô?</string>
|
||||
<string name="exit_message">Ván đang chơi sẽ được lưu lại.</string>
|
||||
<string name="exit_confirm">Thoát</string>
|
||||
<string name="exit_cancel">Ở lại</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Framework-level shell theme only; the real theming (colors, dark
|
||||
mode) lives in Compose MaterialTheme. windowBackground matches the
|
||||
web's page background so the pre-composition frame doesn't flash.
|
||||
Night variant lives in values-night/. -->
|
||||
<style name="Theme.Loto" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<item name="android:windowBackground">@color/splash_background</item>
|
||||
</style>
|
||||
|
||||
<!-- Launch theme: API 31+ system splash via core-splashscreen; the
|
||||
android:background item is the compat splash for API < 31. -->
|
||||
<style name="Theme.Loto.Splash" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">@color/splash_background</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@mipmap/ic_launcher_foreground</item>
|
||||
<item name="postSplashScreenTheme">@style/Theme.Loto</item>
|
||||
<item name="android:background">@drawable/splash</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.miti99.loto
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* In-memory [DataStore] for JVM unit tests. File-backed DataStore cannot be
|
||||
* exercised on a Windows JVM (its atomic rename fails when the target file
|
||||
* exists — java.io.File.renameTo semantics), and repositories only need the
|
||||
* [DataStore] contract anyway: "process death" is simulated by pointing a
|
||||
* fresh repository at the same store instance.
|
||||
*/
|
||||
class InMemoryDataStore(
|
||||
initial: Preferences = emptyPreferences(),
|
||||
) : DataStore<Preferences> {
|
||||
|
||||
private val state = MutableStateFlow(initial)
|
||||
private val mutex = Mutex()
|
||||
|
||||
override val data: Flow<Preferences> = state
|
||||
|
||||
override suspend fun updateData(
|
||||
transform: suspend (t: Preferences) -> Preferences,
|
||||
): Preferences = mutex.withLock {
|
||||
val next = transform(state.value)
|
||||
state.value = next
|
||||
next
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.miti99.loto.audio
|
||||
|
||||
/**
|
||||
* Recording fake for [VoicePlayerApi]. [utterances] logs every speak call;
|
||||
* [active] models the single in-flight utterance ([speak] replaces it —
|
||||
* the cancellation contract — and [cancel] clears it).
|
||||
*/
|
||||
class FakeVoicePlayer : VoicePlayerApi {
|
||||
|
||||
override var voiceId: String = "hoai-my"
|
||||
|
||||
val utterances = mutableListOf<List<String>>()
|
||||
|
||||
var active: List<String>? = null
|
||||
private set
|
||||
|
||||
var released = false
|
||||
private set
|
||||
|
||||
override fun speak(clipNames: List<String>) {
|
||||
utterances.add(clipNames)
|
||||
active = clipNames
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
active = null
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
released = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.miti99.loto.audio
|
||||
|
||||
import java.io.File
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class VoiceCatalogTest {
|
||||
|
||||
@Test
|
||||
fun `parses the real bundled manifest`() {
|
||||
// The same file Gradle mounts into assets/ — read straight from the
|
||||
// web app's static dir (test working dir is the app module).
|
||||
val manifest = File("../../web/static/audio/manifest.json")
|
||||
assertTrue("expected ${manifest.absolutePath} to exist", manifest.exists())
|
||||
val voices = VoiceCatalog.parse(manifest.readText())
|
||||
assertEquals(listOf("hoai-my", "nam-minh"), voices.map { it.id })
|
||||
assertEquals("vi-VN-HoaiMyNeural", voices[0].edgeName)
|
||||
assertEquals("female", voices[0].gender)
|
||||
assertEquals("Nam Minh (nam)", voices[1].label)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back to the known voices on corrupt JSON`() {
|
||||
assertEquals(VoiceCatalog.FALLBACK_VOICES, VoiceCatalog.parse("{not json"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back when the voices key is missing`() {
|
||||
assertEquals(VoiceCatalog.FALLBACK_VOICES, VoiceCatalog.parse("""{"other": 1}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back when the voices array is empty`() {
|
||||
assertEquals(VoiceCatalog.FALLBACK_VOICES, VoiceCatalog.parse("""{"voices": []}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back when an entry misses a field`() {
|
||||
assertEquals(
|
||||
VoiceCatalog.FALLBACK_VOICES,
|
||||
VoiceCatalog.parse("""{"voices": [{"id": "x"}]}"""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `default voice is the first manifest entry`() {
|
||||
val voices = listOf(
|
||||
Voice("nam-minh", "vi-VN-NamMinhNeural", "Nam Minh (nam)", "male"),
|
||||
Voice("hoai-my", "vi-VN-HoaiMyNeural", "Hoai My (nữ)", "female"),
|
||||
)
|
||||
assertEquals("nam-minh", VoiceCatalog.defaultVoiceId(voices))
|
||||
assertEquals("hoai-my", VoiceCatalog.defaultVoiceId(emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clipPath builds the asset-relative mp3 path`() {
|
||||
assertEquals("audio/hoai-my/42.mp3", VoiceCatalog.clipPath("hoai-my", "42"))
|
||||
assertEquals("audio/nam-minh/cho.mp3", VoiceCatalog.clipPath("nam-minh", VoiceCatalog.CLIP_CHO))
|
||||
assertEquals("audio/hoai-my/kinh.mp3", VoiceCatalog.clipPath("hoai-my", VoiceCatalog.CLIP_KINH))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.miti99.loto.audio
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Twins of `web/src/lib/voice.test.js` against the recording fake — the
|
||||
* cancellation and chờ-sequencing contracts the production player must obey.
|
||||
*/
|
||||
class VoicePlayerTest {
|
||||
|
||||
@Test
|
||||
fun `cancel clears the active clip`() {
|
||||
val player = FakeVoicePlayer()
|
||||
player.playNumber(7)
|
||||
assertEquals(listOf("7"), player.active)
|
||||
player.cancel()
|
||||
assertNull(player.active)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a second playNumber cancels the first`() {
|
||||
val player = FakeVoicePlayer()
|
||||
player.playNumber(3)
|
||||
player.playNumber(5)
|
||||
assertEquals(listOf(listOf("3"), listOf("5")), player.utterances)
|
||||
// Only the second utterance is in flight.
|
||||
assertEquals(listOf("5"), player.active)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `playWaiting plays only cho when voiceWaitingNumber is off`() {
|
||||
val player = FakeVoicePlayer()
|
||||
player.playWaiting(42, voiceWaitingNumber = false, modeIsBoth = false)
|
||||
assertEquals(listOf(listOf("cho")), player.utterances)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `playWaiting chains cho then number when voiceWaitingNumber is on`() {
|
||||
val player = FakeVoicePlayer()
|
||||
player.playWaiting(42, voiceWaitingNumber = true, modeIsBoth = false)
|
||||
assertEquals(listOf(listOf("cho", "42")), player.utterances)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `playWaiting suppresses trailing number in both mode even when flag is on`() {
|
||||
val player = FakeVoicePlayer()
|
||||
player.playWaiting(42, voiceWaitingNumber = true, modeIsBoth = true)
|
||||
assertEquals(listOf(listOf("cho")), player.utterances)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `playBingo speaks the kinh clip`() {
|
||||
val player = FakeVoicePlayer()
|
||||
player.playBingo()
|
||||
assertEquals(listOf(listOf("kinh")), player.utterances)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.miti99.loto.game
|
||||
|
||||
import kotlin.random.Random
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Ported one-to-one from `web/src/lib/game-logic.test.js` (generateGrid
|
||||
* describe blocks). Test names mirror the web `it` strings.
|
||||
*/
|
||||
class CardGeneratorTest {
|
||||
|
||||
private val numRows = CardGenerator.NUM_ROWS
|
||||
private val numCols = CardGenerator.NUM_COLS
|
||||
private val numPerRow = CardGenerator.NUM_PER_ROW
|
||||
|
||||
/** Column N (0-indexed) holds numbers in this tens range. */
|
||||
private val colRange = listOf(
|
||||
1 to 9, 10 to 19, 20 to 29, 30 to 39, 40 to 49,
|
||||
50 to 59, 60 to 69, 70 to 79, 80 to 90,
|
||||
)
|
||||
|
||||
private fun rowSums(grid: List<List<Int>>) = grid.map { row -> row.count { it > 0 } }
|
||||
|
||||
private fun colSums(grid: List<List<Int>>) =
|
||||
(0 until numCols).map { c -> grid.count { row -> row[c] > 0 } }
|
||||
|
||||
// -- shape invariants --
|
||||
|
||||
@Test
|
||||
fun `returns a 9x9 matrix`() {
|
||||
val g = CardGenerator.generateGrid()
|
||||
assertEquals(numRows, g.size)
|
||||
for (row in g) assertEquals(numCols, row.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each row has exactly 5 non-zero numbers`() {
|
||||
repeat(200) {
|
||||
assertEquals(List(numRows) { numPerRow }, rowSums(CardGenerator.generateGrid()))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each column has exactly 5 non-zero numbers`() {
|
||||
repeat(200) {
|
||||
assertEquals(List(numCols) { numPerRow }, colSums(CardGenerator.generateGrid()))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `never produces duplicates in a single card`() {
|
||||
repeat(50) {
|
||||
val flat = CardGenerator.generateGrid().flatten().filter { it > 0 }
|
||||
assertEquals(flat.size, flat.toSet().size)
|
||||
}
|
||||
}
|
||||
|
||||
// -- column number ranges (lô tô hội chợ Tân Tân) --
|
||||
|
||||
@Test
|
||||
fun `each non-zero cell sits in its column's tens range`() {
|
||||
repeat(50) {
|
||||
val g = CardGenerator.generateGrid()
|
||||
for (r in 0 until numRows) {
|
||||
for (c in 0 until numCols) {
|
||||
val n = g[r][c]
|
||||
if (n == 0) continue
|
||||
val (lo, hi) = colRange[c]
|
||||
assertTrue("row=$r col=$c num=$n", n in lo..hi)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `numbers within each column are sorted ascending top-to-bottom`() {
|
||||
repeat(50) {
|
||||
val g = CardGenerator.generateGrid()
|
||||
for (c in 0 until numCols) {
|
||||
val colNums = g.map { it[c] }.filter { it > 0 }
|
||||
assertEquals(colNums.sorted(), colNums)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no row has 3 consecutive filled columns (rejection-sampled soft constraint)`() {
|
||||
repeat(300) { trial ->
|
||||
val g = CardGenerator.generateGrid()
|
||||
for (r in 0 until numRows) {
|
||||
for (c in 0 until numCols - 2) {
|
||||
assertTrue(
|
||||
"trial=$trial row=$r cols $c,${c + 1},${c + 2}",
|
||||
!(g[r][c] > 0 && g[r][c + 1] > 0 && g[r][c + 2] > 0),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `col 0 only holds numbers from 1-9 (5 per card)`() {
|
||||
val g = CardGenerator.generateGrid()
|
||||
val col0 = g.map { it[0] }.filter { it > 0 }
|
||||
assertEquals(5, col0.size)
|
||||
for (n in col0) assertTrue(n in 1..9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `col 8 only holds numbers from 80-90 (5 per card)`() {
|
||||
val g = CardGenerator.generateGrid()
|
||||
val col8 = g.map { it[8] }.filter { it > 0 }
|
||||
assertEquals(5, col8.size)
|
||||
for (n in col8) assertTrue(n in 80..90)
|
||||
}
|
||||
|
||||
// -- native-only property test with injected deterministic Random --
|
||||
|
||||
@Test
|
||||
fun `1000 seeded cards all satisfy row, column and decade invariants`() {
|
||||
repeat(1000) { seed ->
|
||||
val g = CardGenerator.generateGrid(Random(seed))
|
||||
assertEquals("seed=$seed rows", List(numRows) { numPerRow }, rowSums(g))
|
||||
assertEquals("seed=$seed cols", List(numCols) { numPerRow }, colSums(g))
|
||||
for (r in 0 until numRows) {
|
||||
for (c in 0 until numCols) {
|
||||
val n = g[r][c]
|
||||
if (n == 0) continue
|
||||
val (lo, hi) = colRange[c]
|
||||
assertTrue("seed=$seed row=$r col=$c num=$n", n in lo..hi)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same seed generates the same card`() {
|
||||
assertEquals(
|
||||
CardGenerator.generateGrid(Random(42)),
|
||||
CardGenerator.generateGrid(Random(42)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.miti99.loto.game
|
||||
|
||||
import kotlin.random.Random
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Ported one-to-one from `web/src/lib/master-store.test.js` (pure deck
|
||||
* semantics; the storage-validation cases have their twins at the
|
||||
* persistence layer). Test names mirror the web `it` strings.
|
||||
*/
|
||||
class DrawDeckTest {
|
||||
|
||||
@Test
|
||||
fun `starts empty`() {
|
||||
val deck = DrawDeck()
|
||||
assertEquals(emptyList<Int>(), deck.called)
|
||||
assertEquals(emptyList<Int>(), deck.remaining)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `startNewGame fills remaining with 90 unique 1 to 90`() {
|
||||
val deck = DrawDeck()
|
||||
deck.startNewGame()
|
||||
assertEquals(emptyList<Int>(), deck.called)
|
||||
assertEquals(90, deck.remaining.size)
|
||||
val set = deck.remaining.toSet()
|
||||
assertEquals(90, set.size)
|
||||
for (n in 1..90) assertTrue(set.contains(n))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `drawNext appends called and shifts remaining, returns drawn`() {
|
||||
val deck = DrawDeck()
|
||||
deck.startNewGame()
|
||||
val first = deck.remaining.first()
|
||||
val drawn = deck.drawNext()
|
||||
assertEquals(first, drawn)
|
||||
assertEquals(listOf(first), deck.called)
|
||||
assertEquals(89, deck.remaining.size)
|
||||
assertFalse(deck.remaining.contains(first))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `drawNext returns null when exhausted`() {
|
||||
val deck = DrawDeck()
|
||||
deck.startNewGame()
|
||||
repeat(90) { deck.drawNext() }
|
||||
assertNull(deck.drawNext())
|
||||
assertEquals(90, deck.called.size)
|
||||
assertEquals(emptyList<Int>(), deck.remaining)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restore round-trip preserves state`() {
|
||||
val deck = DrawDeck()
|
||||
deck.startNewGame()
|
||||
deck.drawNext()
|
||||
deck.drawNext()
|
||||
val calledBefore = deck.called
|
||||
val remainingBefore = deck.remaining
|
||||
|
||||
val restored = DrawDeck()
|
||||
restored.restore(calledBefore, remainingBefore)
|
||||
assertEquals(calledBefore, restored.called)
|
||||
assertEquals(remainingBefore, restored.remaining)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `seeded deck draws deterministically and in monotonic call order`() {
|
||||
val a = DrawDeck(Random(7))
|
||||
val b = DrawDeck(Random(7))
|
||||
a.startNewGame()
|
||||
b.startNewGame()
|
||||
val drawsA = (1..90).mapNotNull { a.drawNext() }
|
||||
val drawsB = (1..90).mapNotNull { b.drawNext() }
|
||||
assertEquals(drawsA, drawsB)
|
||||
assertEquals(drawsA, a.called)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user