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:
tiennm99 committed 2026-08-31 13:28:48 +07:00
1 parent 8f9b03ca05
commit e314ae01c8
126 files changed
+6390 -2275

No files matched your search

+3 -14
View File
@@ -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
+39 -19
View File
@@ -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.
+8 -14
View File
@@ -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
+22 -15
View File
@@ -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
View File
@@ -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
View File
@@ -1,258 +1,88 @@
# Lô tô — Android (Capacitor wrapper)
# Lô tô — Android
![ci](https://github.com/tiennm99/loto/actions/workflows/ci.yml/badge.svg)
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`).
-101
View File
@@ -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
-2
View File
@@ -1,2 +0,0 @@
/build/*
!/build/.npmkeep
-72
View File
@@ -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
View File
@@ -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
@@ -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);
}
}
-29
View File
@@ -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')
-22
View File
@@ -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
-5
View File
@@ -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'
-16
View File
@@ -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'
}
+105
View File
@@ -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)
}
+3
View File
@@ -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()
}
}
+56
View File
@@ -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
@@ -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>
+104
View File
@@ -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