commit fe52232a5321efef9706c7d2dc1980fc8ebd5fd5 Author: tiennm99 Date: Mon Apr 27 19:33:16 2026 +0700 feat: initial Android port of tiennm99/loto Native Android port of the SvelteKit Lô tô hội chợ Tân Tân web app. Stack: - Kotlin 2.1 + Jetpack Compose + Material 3 - Single Activity, single Gradle module (:app) - Audio: AndroidX Media3 ExoPlayer with bundled MP3 voice clips - Settings: DataStore Preferences with legacy masterMode migration - minSdk 24, targetSdk 35, JDK 17 Game logic: - 9x9 player card with exactly 5 numbers per row AND per column - Soft constraint: no 3 consecutive filled columns (rejection-sampled) - 11x9 ones-digit-aligned master tracking board - Bingo / Cho ("waiting") state machine ported from PlayerBoard.svelte - Forward-only auto-tick from MasterPanel via app-scoped CallBus Audio: - 184 MP3s pre-generated with Microsoft Edge TTS (Hoai My, Nam Minh) - ExoPlayer playlist for cho + N gapless sequence - Token-based cancellation matching the web voice.js semantics CI: - build-debug.yml: lint + test + assembleDebug on push/PR - release.yml: signed AAB+APK on v* tag, env-driven keystore diff --git a/.github/workflows/build-debug.yml b/.github/workflows/build-debug.yml new file mode 100644 index 0000000..28495dc --- /dev/null +++ b/.github/workflows/build-debug.yml @@ -0,0 +1,32 @@ +name: build-debug + +on: + pull_request: + push: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Lint + unit tests + assemble debug + run: ./gradlew lint test :app:assembleDebug + + - name: Upload debug APK + uses: actions/upload-artifact@v4 + with: + name: debug-apk + path: app/build/outputs/apk/debug/*.apk + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b367664 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,39 @@ +name: release + +on: + push: + tags: ['v*.*.*'] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Decode keystore + run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > keystore.jks + + - name: Build release bundle and APK + run: ./gradlew :app:bundleRelease :app:assembleRelease + env: + LOTO_KEYSTORE_PATH: ${{ github.workspace }}/keystore.jks + LOTO_KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + LOTO_KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + LOTO_KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + + - name: Upload to GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: | + app/build/outputs/apk/release/*.apk + app/build/outputs/bundle/release/*.aab diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43413b9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +.idea +local.properties +.DS_Store +build +captures +.externalNativeBuild +.cxx +*.apk +*.aab +*.keystore +*.jks +google-services.json +proguard/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..f687401 --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# Lô tô — Android + +![build-debug](https://github.com/tiennm99/loto-android/actions/workflows/build-debug.yml/badge.svg) + +Native Android port of [tiennm99/loto](https://github.com/tiennm99/loto) (SvelteKit web app). + +Lô tô hội chợ TN1 — Vietnamese fairground bingo with host (quản trò) + player modes, +called-number speech in Vietnamese (Hoài My / Nam Minh voices). + +## Stack + +- Kotlin 2.1 + Jetpack Compose + Material 3 +- Single Activity, single Gradle module (`:app`) +- Audio: AndroidX Media3 ExoPlayer (bundled MP3s in `assets/audio/`) +- Settings persistence: DataStore Preferences +- minSdk 24 · targetSdk 35 · JDK 17 + +## Build + +### Debug APK (no signing required) + +```bash +./gradlew :app:assembleDebug +# Output: app/build/outputs/apk/debug/app-debug.apk +``` + +### Install on connected device/emulator + +```bash +./gradlew :app:installDebug +``` + +### Open in Android Studio + +Open the repo root in Android Studio Ladybug (2024.2) or newer. The IDE picks +up the Gradle wrapper automatically. Run the `app` configuration on any API 24+ +device or emulator. + +## Tests + +### Unit tests (JVM, no device needed) + +```bash +./gradlew :app:test +``` + +Covers: `GameLogicTest`, `VietnameseNumberTest`, `VoiceManifestTest`, +`SettingsRepositoryTest`, `CallBusTest`, `PlayerBoardViewModelTest`, +`MasterPanelViewModelTest`, `MasterBoardLayoutTest`. + +### Instrumentation tests (requires connected device or emulator) + +```bash +./gradlew :app:connectedDebugAndroidTest +``` + +Includes `MainActivityComposeSmokeTest` — cold-start → generate grid → assert 81 cells. + +## Release signing + +Release builds read signing credentials from environment variables so the +keystore is never committed to the repository. + +```bash +export LOTO_KEYSTORE_PATH=/path/to/loto-release.jks +export LOTO_KEYSTORE_PASSWORD= +export LOTO_KEY_ALIAS= +export LOTO_KEY_PASSWORD= + +./gradlew :app:assembleRelease # signed APK +./gradlew :app:bundleRelease # signed AAB for Play Store +``` + +Local convention: store the keystore at `~/.android/loto-release.jks`. + +### GitHub Secrets (for CI) + +Set the following secrets in the repository settings under **Settings → Secrets → Actions**: + +| Secret | Description | +|--------|-------------| +| `KEYSTORE_BASE64` | `base64 -w0 loto-release.jks` output | +| `KEYSTORE_PASSWORD` | Keystore password | +| `KEY_ALIAS` | Key alias inside the keystore | +| `KEY_PASSWORD` | Key password | + +**Never commit `keystore.jks`, `.env`, or any file containing these values.** + +## CI / CD + +| Workflow | Trigger | Tasks | +|----------|---------|-------| +| `build-debug` | push to `main`, any PR | `lint test assembleDebug` | +| `release` | tag `v*.*.*` | `bundleRelease assembleRelease` + sign + upload to GH Release | + +Tag a release: + +```bash +git tag v1.0.0 +git push origin v1.0.0 +``` + +The `release` workflow will sign and attach the APK + AAB to the GitHub Release page. + +## Version bump + +1. Increment `versionCode` and `versionName` in `app/build.gradle.kts`. +2. Commit, tag, push tag. + +## App ID + +`com.miti99.loto` — registered in `AndroidManifest.xml` and `app/build.gradle.kts`. + +## Audio + +Bundled MP3 voice clips under `assets/audio/{hoai-my,nam-minh}/{1..90,cho,kinh}.mp3`. +Generated from the upstream repo's `scripts/generate-audio.py` using Microsoft Edge TTS. +Re-generate by running that script against a Python 3.10+ environment with `edge-tts` installed. + +## License + +Same license as upstream [`tiennm99/loto`](https://github.com/tiennm99/loto). diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..1d54039 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,112 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.android.junit5) +} + +android { + namespace = "com.miti99.loto" + compileSdk = 35 + + defaultConfig { + applicationId = "com.miti99.loto" + minSdk = 24 + targetSdk = 35 + versionCode = 1 + versionName = "1.0.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + signingConfigs { + create("release") { + val keystorePath = System.getenv("LOTO_KEYSTORE_PATH") + if (keystorePath != null) { + storeFile = file(keystorePath) + storePassword = System.getenv("LOTO_KEYSTORE_PASSWORD") + keyAlias = System.getenv("LOTO_KEY_ALIAS") + keyPassword = System.getenv("LOTO_KEY_PASSWORD") + } + } + } + + buildTypes { + debug { + isMinifyEnabled = false + } + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + // Only sign when CI env vars are set; local release builds omit signing + val keystorePath = System.getenv("LOTO_KEYSTORE_PATH") + signingConfig = if (keystorePath != null) signingConfigs.getByName("release") else null + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + buildConfig = false + } + + androidResources { + // MP3 voice clips ship pre-compressed; never deflate them. + noCompress += "mp3" + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + implementation(platform(libs.compose.bom)) + implementation(libs.bundles.compose.ui) + + // Audio + implementation(libs.media3.exoplayer) + implementation(libs.media3.common) + + // Persistence + Serialization + implementation(libs.datastore.preferences) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.android) + + // Java 17 desugaring (java.time, etc. on minSdk 24) + coreLibraryDesugaring(libs.desugar.jdk.libs) + + debugImplementation(libs.compose.ui.tooling) + debugImplementation(libs.compose.ui.test.manifest) + + // JUnit 5 (JVM unit tests) + testImplementation(libs.junit.jupiter) + testImplementation(libs.junit.jupiter.params) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.turbine) + + // Instrumentation tests (added in phase-04 / phase-11) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.compose.ui.test.junit4) +} + +tasks.withType().configureEach { + useJUnitPlatform() +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..a052988 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,24 @@ +# Lô tô Android — ProGuard / R8 rules +# Default mode (not fullMode) keeps reflection-heavy Kotlin/AndroidX intact. + +# kotlinx.serialization — keep companion @Serializer references R8 can't infer +-keep,includedescriptorclasses class com.miti99.loto.**$$serializer { *; } +-keepclassmembers class com.miti99.loto.** { + *** Companion; +} +-keepclasseswithmembers class com.miti99.loto.** { + kotlinx.serialization.KSerializer serializer(...); +} + +# Keep Kotlin metadata for runtime reflection +-keep class kotlin.Metadata { *; } + +# Media3 / ExoPlayer internals — prevent stripping of codec/extractor classes +-keep class androidx.media3.** { *; } +-dontwarn androidx.media3.** + +# DataStore +-keep class androidx.datastore.** { *; } + +# Compose tooling only exists in debug; suppress release warnings +-dontwarn androidx.compose.ui.tooling.** diff --git a/app/src/androidTest/java/com/miti99/loto/MainActivityComposeSmokeTest.kt b/app/src/androidTest/java/com/miti99/loto/MainActivityComposeSmokeTest.kt new file mode 100644 index 0000000..8596eb0 --- /dev/null +++ b/app/src/androidTest/java/com/miti99/loto/MainActivityComposeSmokeTest.kt @@ -0,0 +1,46 @@ +package com.miti99.loto + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Compose UI smoke test — verifies the full cold-start → generate-grid flow. + * + * Requires a connected device or emulator. Run via: + * ./gradlew :app:connectedDebugAndroidTest + * + * Phase 11 spec: launch MainActivity, assert "Tạo bảng mới" shown, + * click it, assert all 81 player_cell nodes are rendered. + */ +@RunWith(AndroidJUnit4::class) +class MainActivityComposeSmokeTest { + + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun generate_button_is_visible_on_cold_start() { + rule.onNodeWithText("Tạo bảng mới").assertExists() + } + + @Test + fun generate_button_creates_81_player_cells() { + // Tap the generate button — no confirmation dialog on first tap (grid is null) + rule.onNodeWithText("Tạo bảng mới").performClick() + + // Wait up to 5 s for all 81 cells to appear (grid generation is synchronous + // but Compose may take a frame to recompose) + rule.waitUntil(timeoutMillis = 5_000) { + rule.onAllNodesWithTag("player_cell").fetchSemanticsNodes().size == 81 + } + + rule.onAllNodesWithTag("player_cell").assertCountEquals(81) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8c1a2ca --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/audio/hoai-my/1.mp3 b/app/src/main/assets/audio/hoai-my/1.mp3 new file mode 100644 index 0000000..1082089 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/1.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/10.mp3 b/app/src/main/assets/audio/hoai-my/10.mp3 new file mode 100644 index 0000000..5bcf2bd Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/10.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/11.mp3 b/app/src/main/assets/audio/hoai-my/11.mp3 new file mode 100644 index 0000000..30354cd Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/11.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/12.mp3 b/app/src/main/assets/audio/hoai-my/12.mp3 new file mode 100644 index 0000000..990133b Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/12.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/13.mp3 b/app/src/main/assets/audio/hoai-my/13.mp3 new file mode 100644 index 0000000..70fa546 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/13.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/14.mp3 b/app/src/main/assets/audio/hoai-my/14.mp3 new file mode 100644 index 0000000..449c4c8 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/14.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/15.mp3 b/app/src/main/assets/audio/hoai-my/15.mp3 new file mode 100644 index 0000000..a249fbc Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/15.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/16.mp3 b/app/src/main/assets/audio/hoai-my/16.mp3 new file mode 100644 index 0000000..c2722db Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/16.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/17.mp3 b/app/src/main/assets/audio/hoai-my/17.mp3 new file mode 100644 index 0000000..78555e5 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/17.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/18.mp3 b/app/src/main/assets/audio/hoai-my/18.mp3 new file mode 100644 index 0000000..0b1963a Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/18.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/19.mp3 b/app/src/main/assets/audio/hoai-my/19.mp3 new file mode 100644 index 0000000..f9c7378 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/19.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/2.mp3 b/app/src/main/assets/audio/hoai-my/2.mp3 new file mode 100644 index 0000000..bbf27d9 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/2.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/20.mp3 b/app/src/main/assets/audio/hoai-my/20.mp3 new file mode 100644 index 0000000..fd9f107 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/20.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/21.mp3 b/app/src/main/assets/audio/hoai-my/21.mp3 new file mode 100644 index 0000000..92ed95d Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/21.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/22.mp3 b/app/src/main/assets/audio/hoai-my/22.mp3 new file mode 100644 index 0000000..e7a65da Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/22.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/23.mp3 b/app/src/main/assets/audio/hoai-my/23.mp3 new file mode 100644 index 0000000..cb0ac4f Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/23.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/24.mp3 b/app/src/main/assets/audio/hoai-my/24.mp3 new file mode 100644 index 0000000..ad3a34e Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/24.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/25.mp3 b/app/src/main/assets/audio/hoai-my/25.mp3 new file mode 100644 index 0000000..d01ac4e Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/25.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/26.mp3 b/app/src/main/assets/audio/hoai-my/26.mp3 new file mode 100644 index 0000000..09614d3 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/26.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/27.mp3 b/app/src/main/assets/audio/hoai-my/27.mp3 new file mode 100644 index 0000000..abdb11a Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/27.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/28.mp3 b/app/src/main/assets/audio/hoai-my/28.mp3 new file mode 100644 index 0000000..710bfb0 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/28.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/29.mp3 b/app/src/main/assets/audio/hoai-my/29.mp3 new file mode 100644 index 0000000..46fe6f3 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/29.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/3.mp3 b/app/src/main/assets/audio/hoai-my/3.mp3 new file mode 100644 index 0000000..693424c Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/3.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/30.mp3 b/app/src/main/assets/audio/hoai-my/30.mp3 new file mode 100644 index 0000000..8e050cc Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/30.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/31.mp3 b/app/src/main/assets/audio/hoai-my/31.mp3 new file mode 100644 index 0000000..12ca306 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/31.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/32.mp3 b/app/src/main/assets/audio/hoai-my/32.mp3 new file mode 100644 index 0000000..9d889e3 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/32.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/33.mp3 b/app/src/main/assets/audio/hoai-my/33.mp3 new file mode 100644 index 0000000..b5daf68 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/33.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/34.mp3 b/app/src/main/assets/audio/hoai-my/34.mp3 new file mode 100644 index 0000000..bfebee6 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/34.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/35.mp3 b/app/src/main/assets/audio/hoai-my/35.mp3 new file mode 100644 index 0000000..0e9273b Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/35.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/36.mp3 b/app/src/main/assets/audio/hoai-my/36.mp3 new file mode 100644 index 0000000..a65f8da Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/36.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/37.mp3 b/app/src/main/assets/audio/hoai-my/37.mp3 new file mode 100644 index 0000000..0f42e0b Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/37.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/38.mp3 b/app/src/main/assets/audio/hoai-my/38.mp3 new file mode 100644 index 0000000..a0b50e4 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/38.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/39.mp3 b/app/src/main/assets/audio/hoai-my/39.mp3 new file mode 100644 index 0000000..391d3a9 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/39.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/4.mp3 b/app/src/main/assets/audio/hoai-my/4.mp3 new file mode 100644 index 0000000..7fca9c5 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/4.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/40.mp3 b/app/src/main/assets/audio/hoai-my/40.mp3 new file mode 100644 index 0000000..713b921 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/40.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/41.mp3 b/app/src/main/assets/audio/hoai-my/41.mp3 new file mode 100644 index 0000000..1970789 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/41.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/42.mp3 b/app/src/main/assets/audio/hoai-my/42.mp3 new file mode 100644 index 0000000..e9537fa Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/42.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/43.mp3 b/app/src/main/assets/audio/hoai-my/43.mp3 new file mode 100644 index 0000000..e54921d Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/43.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/44.mp3 b/app/src/main/assets/audio/hoai-my/44.mp3 new file mode 100644 index 0000000..5ba69fa Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/44.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/45.mp3 b/app/src/main/assets/audio/hoai-my/45.mp3 new file mode 100644 index 0000000..d5f9feb Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/45.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/46.mp3 b/app/src/main/assets/audio/hoai-my/46.mp3 new file mode 100644 index 0000000..a2045c4 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/46.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/47.mp3 b/app/src/main/assets/audio/hoai-my/47.mp3 new file mode 100644 index 0000000..41afb96 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/47.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/48.mp3 b/app/src/main/assets/audio/hoai-my/48.mp3 new file mode 100644 index 0000000..9376edb Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/48.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/49.mp3 b/app/src/main/assets/audio/hoai-my/49.mp3 new file mode 100644 index 0000000..33aed79 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/49.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/5.mp3 b/app/src/main/assets/audio/hoai-my/5.mp3 new file mode 100644 index 0000000..d48c2ca Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/5.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/50.mp3 b/app/src/main/assets/audio/hoai-my/50.mp3 new file mode 100644 index 0000000..ad9b966 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/50.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/51.mp3 b/app/src/main/assets/audio/hoai-my/51.mp3 new file mode 100644 index 0000000..d48d503 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/51.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/52.mp3 b/app/src/main/assets/audio/hoai-my/52.mp3 new file mode 100644 index 0000000..430b17a Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/52.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/53.mp3 b/app/src/main/assets/audio/hoai-my/53.mp3 new file mode 100644 index 0000000..fa6c3a0 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/53.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/54.mp3 b/app/src/main/assets/audio/hoai-my/54.mp3 new file mode 100644 index 0000000..b06e248 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/54.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/55.mp3 b/app/src/main/assets/audio/hoai-my/55.mp3 new file mode 100644 index 0000000..be72a52 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/55.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/56.mp3 b/app/src/main/assets/audio/hoai-my/56.mp3 new file mode 100644 index 0000000..4428746 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/56.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/57.mp3 b/app/src/main/assets/audio/hoai-my/57.mp3 new file mode 100644 index 0000000..3c8b9b6 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/57.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/58.mp3 b/app/src/main/assets/audio/hoai-my/58.mp3 new file mode 100644 index 0000000..9ae2523 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/58.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/59.mp3 b/app/src/main/assets/audio/hoai-my/59.mp3 new file mode 100644 index 0000000..168675e Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/59.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/6.mp3 b/app/src/main/assets/audio/hoai-my/6.mp3 new file mode 100644 index 0000000..9ffd8c7 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/6.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/60.mp3 b/app/src/main/assets/audio/hoai-my/60.mp3 new file mode 100644 index 0000000..f36398b Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/60.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/61.mp3 b/app/src/main/assets/audio/hoai-my/61.mp3 new file mode 100644 index 0000000..76e431c Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/61.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/62.mp3 b/app/src/main/assets/audio/hoai-my/62.mp3 new file mode 100644 index 0000000..f5ed67f Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/62.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/63.mp3 b/app/src/main/assets/audio/hoai-my/63.mp3 new file mode 100644 index 0000000..65860aa Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/63.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/64.mp3 b/app/src/main/assets/audio/hoai-my/64.mp3 new file mode 100644 index 0000000..d0f7dc2 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/64.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/65.mp3 b/app/src/main/assets/audio/hoai-my/65.mp3 new file mode 100644 index 0000000..ce024f9 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/65.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/66.mp3 b/app/src/main/assets/audio/hoai-my/66.mp3 new file mode 100644 index 0000000..86f6a99 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/66.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/67.mp3 b/app/src/main/assets/audio/hoai-my/67.mp3 new file mode 100644 index 0000000..872df05 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/67.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/68.mp3 b/app/src/main/assets/audio/hoai-my/68.mp3 new file mode 100644 index 0000000..b791586 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/68.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/69.mp3 b/app/src/main/assets/audio/hoai-my/69.mp3 new file mode 100644 index 0000000..e080bc0 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/69.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/7.mp3 b/app/src/main/assets/audio/hoai-my/7.mp3 new file mode 100644 index 0000000..a440189 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/7.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/70.mp3 b/app/src/main/assets/audio/hoai-my/70.mp3 new file mode 100644 index 0000000..cfac4ff Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/70.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/71.mp3 b/app/src/main/assets/audio/hoai-my/71.mp3 new file mode 100644 index 0000000..6742732 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/71.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/72.mp3 b/app/src/main/assets/audio/hoai-my/72.mp3 new file mode 100644 index 0000000..63c8aa1 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/72.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/73.mp3 b/app/src/main/assets/audio/hoai-my/73.mp3 new file mode 100644 index 0000000..8f0427c Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/73.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/74.mp3 b/app/src/main/assets/audio/hoai-my/74.mp3 new file mode 100644 index 0000000..c2be046 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/74.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/75.mp3 b/app/src/main/assets/audio/hoai-my/75.mp3 new file mode 100644 index 0000000..d714ec2 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/75.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/76.mp3 b/app/src/main/assets/audio/hoai-my/76.mp3 new file mode 100644 index 0000000..48182e8 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/76.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/77.mp3 b/app/src/main/assets/audio/hoai-my/77.mp3 new file mode 100644 index 0000000..c9a1579 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/77.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/78.mp3 b/app/src/main/assets/audio/hoai-my/78.mp3 new file mode 100644 index 0000000..5941a78 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/78.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/79.mp3 b/app/src/main/assets/audio/hoai-my/79.mp3 new file mode 100644 index 0000000..6fdc916 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/79.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/8.mp3 b/app/src/main/assets/audio/hoai-my/8.mp3 new file mode 100644 index 0000000..62673bf Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/8.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/80.mp3 b/app/src/main/assets/audio/hoai-my/80.mp3 new file mode 100644 index 0000000..96adcfd Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/80.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/81.mp3 b/app/src/main/assets/audio/hoai-my/81.mp3 new file mode 100644 index 0000000..fd199e4 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/81.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/82.mp3 b/app/src/main/assets/audio/hoai-my/82.mp3 new file mode 100644 index 0000000..7e857c5 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/82.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/83.mp3 b/app/src/main/assets/audio/hoai-my/83.mp3 new file mode 100644 index 0000000..b09d22b Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/83.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/84.mp3 b/app/src/main/assets/audio/hoai-my/84.mp3 new file mode 100644 index 0000000..21796bd Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/84.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/85.mp3 b/app/src/main/assets/audio/hoai-my/85.mp3 new file mode 100644 index 0000000..0c11919 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/85.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/86.mp3 b/app/src/main/assets/audio/hoai-my/86.mp3 new file mode 100644 index 0000000..0c0dc4e Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/86.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/87.mp3 b/app/src/main/assets/audio/hoai-my/87.mp3 new file mode 100644 index 0000000..684647c Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/87.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/88.mp3 b/app/src/main/assets/audio/hoai-my/88.mp3 new file mode 100644 index 0000000..ecf88fe Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/88.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/89.mp3 b/app/src/main/assets/audio/hoai-my/89.mp3 new file mode 100644 index 0000000..0693964 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/89.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/9.mp3 b/app/src/main/assets/audio/hoai-my/9.mp3 new file mode 100644 index 0000000..a57734b Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/9.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/90.mp3 b/app/src/main/assets/audio/hoai-my/90.mp3 new file mode 100644 index 0000000..a21b9bf Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/90.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/cho.mp3 b/app/src/main/assets/audio/hoai-my/cho.mp3 new file mode 100644 index 0000000..9ba3441 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/cho.mp3 differ diff --git a/app/src/main/assets/audio/hoai-my/kinh.mp3 b/app/src/main/assets/audio/hoai-my/kinh.mp3 new file mode 100644 index 0000000..8489d86 Binary files /dev/null and b/app/src/main/assets/audio/hoai-my/kinh.mp3 differ diff --git a/app/src/main/assets/audio/manifest.json b/app/src/main/assets/audio/manifest.json new file mode 100644 index 0000000..cf35192 --- /dev/null +++ b/app/src/main/assets/audio/manifest.json @@ -0,0 +1,16 @@ +{ + "voices": [ + { + "id": "hoai-my", + "edgeName": "vi-VN-HoaiMyNeural", + "label": "Hoai My (nữ)", + "gender": "female" + }, + { + "id": "nam-minh", + "edgeName": "vi-VN-NamMinhNeural", + "label": "Nam Minh (nam)", + "gender": "male" + } + ] +} \ No newline at end of file diff --git a/app/src/main/assets/audio/nam-minh/1.mp3 b/app/src/main/assets/audio/nam-minh/1.mp3 new file mode 100644 index 0000000..a1d86d3 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/1.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/10.mp3 b/app/src/main/assets/audio/nam-minh/10.mp3 new file mode 100644 index 0000000..3ef5ee3 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/10.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/11.mp3 b/app/src/main/assets/audio/nam-minh/11.mp3 new file mode 100644 index 0000000..07458bc Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/11.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/12.mp3 b/app/src/main/assets/audio/nam-minh/12.mp3 new file mode 100644 index 0000000..570e811 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/12.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/13.mp3 b/app/src/main/assets/audio/nam-minh/13.mp3 new file mode 100644 index 0000000..869450e Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/13.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/14.mp3 b/app/src/main/assets/audio/nam-minh/14.mp3 new file mode 100644 index 0000000..04c8d61 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/14.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/15.mp3 b/app/src/main/assets/audio/nam-minh/15.mp3 new file mode 100644 index 0000000..42c554b Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/15.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/16.mp3 b/app/src/main/assets/audio/nam-minh/16.mp3 new file mode 100644 index 0000000..e78e9b8 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/16.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/17.mp3 b/app/src/main/assets/audio/nam-minh/17.mp3 new file mode 100644 index 0000000..14601fb Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/17.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/18.mp3 b/app/src/main/assets/audio/nam-minh/18.mp3 new file mode 100644 index 0000000..653ef24 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/18.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/19.mp3 b/app/src/main/assets/audio/nam-minh/19.mp3 new file mode 100644 index 0000000..ecbbdae Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/19.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/2.mp3 b/app/src/main/assets/audio/nam-minh/2.mp3 new file mode 100644 index 0000000..b77123e Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/2.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/20.mp3 b/app/src/main/assets/audio/nam-minh/20.mp3 new file mode 100644 index 0000000..a33282e Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/20.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/21.mp3 b/app/src/main/assets/audio/nam-minh/21.mp3 new file mode 100644 index 0000000..7896e63 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/21.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/22.mp3 b/app/src/main/assets/audio/nam-minh/22.mp3 new file mode 100644 index 0000000..f18f88e Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/22.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/23.mp3 b/app/src/main/assets/audio/nam-minh/23.mp3 new file mode 100644 index 0000000..d16db50 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/23.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/24.mp3 b/app/src/main/assets/audio/nam-minh/24.mp3 new file mode 100644 index 0000000..5a59768 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/24.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/25.mp3 b/app/src/main/assets/audio/nam-minh/25.mp3 new file mode 100644 index 0000000..8ebf939 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/25.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/26.mp3 b/app/src/main/assets/audio/nam-minh/26.mp3 new file mode 100644 index 0000000..a7d7af8 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/26.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/27.mp3 b/app/src/main/assets/audio/nam-minh/27.mp3 new file mode 100644 index 0000000..2ce8ce8 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/27.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/28.mp3 b/app/src/main/assets/audio/nam-minh/28.mp3 new file mode 100644 index 0000000..5fd24b0 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/28.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/29.mp3 b/app/src/main/assets/audio/nam-minh/29.mp3 new file mode 100644 index 0000000..e1b4f7c Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/29.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/3.mp3 b/app/src/main/assets/audio/nam-minh/3.mp3 new file mode 100644 index 0000000..0fd75d2 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/3.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/30.mp3 b/app/src/main/assets/audio/nam-minh/30.mp3 new file mode 100644 index 0000000..36497d9 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/30.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/31.mp3 b/app/src/main/assets/audio/nam-minh/31.mp3 new file mode 100644 index 0000000..10de234 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/31.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/32.mp3 b/app/src/main/assets/audio/nam-minh/32.mp3 new file mode 100644 index 0000000..ed59011 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/32.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/33.mp3 b/app/src/main/assets/audio/nam-minh/33.mp3 new file mode 100644 index 0000000..3621289 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/33.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/34.mp3 b/app/src/main/assets/audio/nam-minh/34.mp3 new file mode 100644 index 0000000..bd2d226 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/34.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/35.mp3 b/app/src/main/assets/audio/nam-minh/35.mp3 new file mode 100644 index 0000000..c2a4e9b Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/35.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/36.mp3 b/app/src/main/assets/audio/nam-minh/36.mp3 new file mode 100644 index 0000000..f49acde Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/36.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/37.mp3 b/app/src/main/assets/audio/nam-minh/37.mp3 new file mode 100644 index 0000000..bc60b08 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/37.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/38.mp3 b/app/src/main/assets/audio/nam-minh/38.mp3 new file mode 100644 index 0000000..d14291b Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/38.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/39.mp3 b/app/src/main/assets/audio/nam-minh/39.mp3 new file mode 100644 index 0000000..0393d8b Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/39.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/4.mp3 b/app/src/main/assets/audio/nam-minh/4.mp3 new file mode 100644 index 0000000..d6573f7 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/4.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/40.mp3 b/app/src/main/assets/audio/nam-minh/40.mp3 new file mode 100644 index 0000000..2218ede Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/40.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/41.mp3 b/app/src/main/assets/audio/nam-minh/41.mp3 new file mode 100644 index 0000000..cfd2d32 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/41.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/42.mp3 b/app/src/main/assets/audio/nam-minh/42.mp3 new file mode 100644 index 0000000..7207fb1 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/42.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/43.mp3 b/app/src/main/assets/audio/nam-minh/43.mp3 new file mode 100644 index 0000000..f3d7874 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/43.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/44.mp3 b/app/src/main/assets/audio/nam-minh/44.mp3 new file mode 100644 index 0000000..c95c555 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/44.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/45.mp3 b/app/src/main/assets/audio/nam-minh/45.mp3 new file mode 100644 index 0000000..5719502 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/45.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/46.mp3 b/app/src/main/assets/audio/nam-minh/46.mp3 new file mode 100644 index 0000000..009c7b0 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/46.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/47.mp3 b/app/src/main/assets/audio/nam-minh/47.mp3 new file mode 100644 index 0000000..a48861b Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/47.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/48.mp3 b/app/src/main/assets/audio/nam-minh/48.mp3 new file mode 100644 index 0000000..776c291 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/48.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/49.mp3 b/app/src/main/assets/audio/nam-minh/49.mp3 new file mode 100644 index 0000000..3e23011 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/49.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/5.mp3 b/app/src/main/assets/audio/nam-minh/5.mp3 new file mode 100644 index 0000000..e089b87 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/5.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/50.mp3 b/app/src/main/assets/audio/nam-minh/50.mp3 new file mode 100644 index 0000000..817af84 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/50.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/51.mp3 b/app/src/main/assets/audio/nam-minh/51.mp3 new file mode 100644 index 0000000..a2c2a4b Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/51.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/52.mp3 b/app/src/main/assets/audio/nam-minh/52.mp3 new file mode 100644 index 0000000..9b2e34e Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/52.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/53.mp3 b/app/src/main/assets/audio/nam-minh/53.mp3 new file mode 100644 index 0000000..e50e610 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/53.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/54.mp3 b/app/src/main/assets/audio/nam-minh/54.mp3 new file mode 100644 index 0000000..b1b5d2a Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/54.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/55.mp3 b/app/src/main/assets/audio/nam-minh/55.mp3 new file mode 100644 index 0000000..1dbca24 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/55.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/56.mp3 b/app/src/main/assets/audio/nam-minh/56.mp3 new file mode 100644 index 0000000..098edf1 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/56.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/57.mp3 b/app/src/main/assets/audio/nam-minh/57.mp3 new file mode 100644 index 0000000..400e067 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/57.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/58.mp3 b/app/src/main/assets/audio/nam-minh/58.mp3 new file mode 100644 index 0000000..4c2690b Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/58.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/59.mp3 b/app/src/main/assets/audio/nam-minh/59.mp3 new file mode 100644 index 0000000..4cee32c Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/59.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/6.mp3 b/app/src/main/assets/audio/nam-minh/6.mp3 new file mode 100644 index 0000000..d3ed32e Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/6.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/60.mp3 b/app/src/main/assets/audio/nam-minh/60.mp3 new file mode 100644 index 0000000..f14c160 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/60.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/61.mp3 b/app/src/main/assets/audio/nam-minh/61.mp3 new file mode 100644 index 0000000..2e8d5ab Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/61.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/62.mp3 b/app/src/main/assets/audio/nam-minh/62.mp3 new file mode 100644 index 0000000..586c4ba Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/62.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/63.mp3 b/app/src/main/assets/audio/nam-minh/63.mp3 new file mode 100644 index 0000000..7bc8b89 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/63.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/64.mp3 b/app/src/main/assets/audio/nam-minh/64.mp3 new file mode 100644 index 0000000..705e307 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/64.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/65.mp3 b/app/src/main/assets/audio/nam-minh/65.mp3 new file mode 100644 index 0000000..0d293c2 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/65.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/66.mp3 b/app/src/main/assets/audio/nam-minh/66.mp3 new file mode 100644 index 0000000..5419539 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/66.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/67.mp3 b/app/src/main/assets/audio/nam-minh/67.mp3 new file mode 100644 index 0000000..3518994 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/67.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/68.mp3 b/app/src/main/assets/audio/nam-minh/68.mp3 new file mode 100644 index 0000000..f72445d Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/68.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/69.mp3 b/app/src/main/assets/audio/nam-minh/69.mp3 new file mode 100644 index 0000000..02573cd Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/69.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/7.mp3 b/app/src/main/assets/audio/nam-minh/7.mp3 new file mode 100644 index 0000000..7c7b7d4 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/7.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/70.mp3 b/app/src/main/assets/audio/nam-minh/70.mp3 new file mode 100644 index 0000000..61f7f73 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/70.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/71.mp3 b/app/src/main/assets/audio/nam-minh/71.mp3 new file mode 100644 index 0000000..6ca7d12 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/71.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/72.mp3 b/app/src/main/assets/audio/nam-minh/72.mp3 new file mode 100644 index 0000000..cc5b621 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/72.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/73.mp3 b/app/src/main/assets/audio/nam-minh/73.mp3 new file mode 100644 index 0000000..0985fd2 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/73.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/74.mp3 b/app/src/main/assets/audio/nam-minh/74.mp3 new file mode 100644 index 0000000..c880f52 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/74.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/75.mp3 b/app/src/main/assets/audio/nam-minh/75.mp3 new file mode 100644 index 0000000..49371cd Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/75.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/76.mp3 b/app/src/main/assets/audio/nam-minh/76.mp3 new file mode 100644 index 0000000..619c2e2 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/76.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/77.mp3 b/app/src/main/assets/audio/nam-minh/77.mp3 new file mode 100644 index 0000000..1e9fcba Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/77.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/78.mp3 b/app/src/main/assets/audio/nam-minh/78.mp3 new file mode 100644 index 0000000..84aba7b Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/78.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/79.mp3 b/app/src/main/assets/audio/nam-minh/79.mp3 new file mode 100644 index 0000000..8176149 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/79.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/8.mp3 b/app/src/main/assets/audio/nam-minh/8.mp3 new file mode 100644 index 0000000..722a1b1 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/8.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/80.mp3 b/app/src/main/assets/audio/nam-minh/80.mp3 new file mode 100644 index 0000000..23f697b Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/80.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/81.mp3 b/app/src/main/assets/audio/nam-minh/81.mp3 new file mode 100644 index 0000000..b4d8115 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/81.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/82.mp3 b/app/src/main/assets/audio/nam-minh/82.mp3 new file mode 100644 index 0000000..597478b Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/82.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/83.mp3 b/app/src/main/assets/audio/nam-minh/83.mp3 new file mode 100644 index 0000000..a5db4f5 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/83.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/84.mp3 b/app/src/main/assets/audio/nam-minh/84.mp3 new file mode 100644 index 0000000..18a052f Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/84.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/85.mp3 b/app/src/main/assets/audio/nam-minh/85.mp3 new file mode 100644 index 0000000..79dcd8f Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/85.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/86.mp3 b/app/src/main/assets/audio/nam-minh/86.mp3 new file mode 100644 index 0000000..48fff31 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/86.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/87.mp3 b/app/src/main/assets/audio/nam-minh/87.mp3 new file mode 100644 index 0000000..88d809c Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/87.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/88.mp3 b/app/src/main/assets/audio/nam-minh/88.mp3 new file mode 100644 index 0000000..f1e914f Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/88.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/89.mp3 b/app/src/main/assets/audio/nam-minh/89.mp3 new file mode 100644 index 0000000..863fe8d Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/89.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/9.mp3 b/app/src/main/assets/audio/nam-minh/9.mp3 new file mode 100644 index 0000000..05aaa30 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/9.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/90.mp3 b/app/src/main/assets/audio/nam-minh/90.mp3 new file mode 100644 index 0000000..2afb396 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/90.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/cho.mp3 b/app/src/main/assets/audio/nam-minh/cho.mp3 new file mode 100644 index 0000000..4fa28e0 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/cho.mp3 differ diff --git a/app/src/main/assets/audio/nam-minh/kinh.mp3 b/app/src/main/assets/audio/nam-minh/kinh.mp3 new file mode 100644 index 0000000..8ab2503 Binary files /dev/null and b/app/src/main/assets/audio/nam-minh/kinh.mp3 differ diff --git a/app/src/main/java/com/miti99/loto/LotoApp.kt b/app/src/main/java/com/miti99/loto/LotoApp.kt new file mode 100644 index 0000000..91a3c33 --- /dev/null +++ b/app/src/main/java/com/miti99/loto/LotoApp.kt @@ -0,0 +1,65 @@ +package com.miti99.loto + +import android.app.Application +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import com.miti99.loto.audio.VoiceManifest +import com.miti99.loto.audio.VoicePlayer +import com.miti99.loto.settings.SettingsRepository +import com.miti99.loto.state.CallBus +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + +/** DataStore extension on the app Context — used only inside [LotoApp]. */ +private val Context.lotoDataStore: DataStore by preferencesDataStore( + name = "loto_settings", + produceMigrations = { listOf(SettingsRepository.legacyMasterModeMigration) }, +) + +/** + * Application singleton. Wires app-scoped state in onCreate. + * + * Composables retrieve singletons through `LocalContext.current.applicationContext as LotoApp` + * and pass them into a `ViewModelProvider.Factory` (see [com.miti99.loto.state.lotoViewModelFactory]). + */ +class LotoApp : Application() { + + val appScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + + val callBus: CallBus = CallBus() + + lateinit var settingsRepo: SettingsRepository + private set + + lateinit var voicePlayer: VoicePlayer + private set + + /** Internal accessor for [com.miti99.loto.state.GameStorage]. */ + internal val lotoDataStoreInternal: DataStore + get() = lotoDataStore + + override fun onCreate() { + super.onCreate() + val voiceIds = runCatching { VoiceManifest.load(this).map { it.id }.toSet() } + .getOrDefault(setOf("hoai-my", "nam-minh")) + + settingsRepo = SettingsRepository( + dataStore = lotoDataStore, + appScope = appScope, + validVoiceIds = voiceIds, + ) + voicePlayer = VoicePlayer( + appContext = applicationContext, + voiceFlow = settingsRepo.voiceFlow, + appScope = appScope, + ) + } + + override fun onTerminate() { + voicePlayer.release() + super.onTerminate() + } +} diff --git a/app/src/main/java/com/miti99/loto/MainActivity.kt b/app/src/main/java/com/miti99/loto/MainActivity.kt new file mode 100644 index 0000000..46bf4dd --- /dev/null +++ b/app/src/main/java/com/miti99/loto/MainActivity.kt @@ -0,0 +1,22 @@ +package com.miti99.loto + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import com.miti99.loto.ui.LotoAppRoot + +/** + * Single Activity — hosts the root Compose tree via [LotoAppRoot]. + * Replaced the Phase 01 placeholder as specified in Phase 10. + */ +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + LotoAppRoot() + } + } +} diff --git a/app/src/main/java/com/miti99/loto/audio/VoiceManifest.kt b/app/src/main/java/com/miti99/loto/audio/VoiceManifest.kt new file mode 100644 index 0000000..b0be4fd --- /dev/null +++ b/app/src/main/java/com/miti99/loto/audio/VoiceManifest.kt @@ -0,0 +1,28 @@ +package com.miti99.loto.audio + +import android.content.Context +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** Parsed entry from `assets/audio/manifest.json`. */ +@Serializable +data class VoiceEntry( + val id: String, + val edgeName: String, + val label: String, + val gender: String, +) + +@Serializable +private data class ManifestRoot(val voices: List) + +object VoiceManifest { + + private val json = Json { ignoreUnknownKeys = true } + + /** Read and parse the bundled voice manifest. Throws on malformed input. */ + fun load(context: Context): List { + val text = context.assets.open("audio/manifest.json").bufferedReader().use { it.readText() } + return json.decodeFromString(text).voices + } +} diff --git a/app/src/main/java/com/miti99/loto/audio/VoicePlayer.kt b/app/src/main/java/com/miti99/loto/audio/VoicePlayer.kt new file mode 100644 index 0000000..b6f0edf --- /dev/null +++ b/app/src/main/java/com/miti99/loto/audio/VoicePlayer.kt @@ -0,0 +1,129 @@ +package com.miti99.loto.audio + +import android.content.Context +import android.os.Handler +import android.os.Looper +import androidx.annotation.OptIn +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch + +/** + * Application-scoped Vietnamese voice playback. Single ExoPlayer instance, + * token-based cancellation matching `tiennm99/loto/src/lib/voice.js`. + * + * One Player handles both voices — assets are addressed by URI, no dual + * instance needed. Voice change cancels in-flight clip; next play uses the + * new voice's path. + */ +@OptIn(UnstableApi::class) +class VoicePlayer( + private val appContext: Context, + private val voiceFlow: StateFlow, + appScope: CoroutineScope, +) { + + private val handler = Handler(Looper.getMainLooper()) + private var activeToken: Any? = null + + private val player: ExoPlayer = ExoPlayer.Builder(appContext) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_SPEECH) + .build(), + /* handleAudioFocus = */ true, + ) + .build() + + init { + player.addListener(object : Player.Listener { + override fun onPlaybackStateChanged(state: Int) { + if (state == Player.STATE_ENDED) { + activeToken = null + } + } + + override fun onPlayerError(error: PlaybackException) { + activeToken = null + } + }) + + // Drop the first emission so initial flow value (current voice) doesn't + // trigger a spurious cancel before any clip plays. + appScope.launch { + voiceFlow.distinctUntilChanged().drop(1).collect { clearCache() } + } + } + + /** Play the clip for `n`. Cancels any in-flight clip first. */ + fun playNumber(n: Int) = playSequence(listOf(n.toString())) + + /** + * Play "Chờ" prefix then optionally the awaited number. Cancels in-flight. + * Mirrors `voice.js:104-115`. + */ + fun playWaiting(n: Int, includeNumber: Boolean) { + val names = if (includeNumber) listOf("cho", n.toString()) else listOf("cho") + playSequence(names) + } + + /** Play the bingo win announcement. Cancels in-flight. */ + fun playBingo() = playSequence(listOf("kinh")) + + /** Stop in-flight playback. Idempotent. */ + fun cancel() { + val token = Any() + activeToken = null + handler.post { + // Use a fresh token only to satisfy the same-thread invariant; we + // don't gate on this one — cancel always wins. + @Suppress("UNUSED_VARIABLE") + val _t = token + player.stop() + player.clearMediaItems() + } + } + + /** + * Drop in-flight playback and force the next call to resolve the current + * voice path fresh. Matches `voice.js:46-49 clearAudioCache` semantics. + */ + fun clearCache() = cancel() + + /** Best-effort cleanup. Process death cleans this up regardless. */ + fun release() { + handler.post { + activeToken = null + player.release() + } + } + + // ----- internals ----- + + private fun playSequence(names: List) { + val token = Any() + activeToken = token + val items = names.map { name -> MediaItem.fromUri(assetUri(name)) } + handler.post { + if (activeToken !== token) return@post + player.stop() + player.clearMediaItems() + player.setMediaItems(items) + player.prepare() + player.playWhenReady = true + } + } + + private fun assetUri(name: String): String = + "asset:///audio/${voiceFlow.value}/$name.mp3" +} diff --git a/app/src/main/java/com/miti99/loto/game/GameLogic.kt b/app/src/main/java/com/miti99/loto/game/GameLogic.kt new file mode 100644 index 0000000..3e9fe0b --- /dev/null +++ b/app/src/main/java/com/miti99/loto/game/GameLogic.kt @@ -0,0 +1,166 @@ +package com.miti99.loto.game + +import kotlin.random.Random + +/** + * Lô tô hội chợ Tân Tân card generator and row-state helpers. + * + * Pure Kotlin port of `tiennm99/loto/src/lib/game-logic.js`. Persistence + * (saveGrid/loadGrid) lives in DataStore (phase 05), not here. + */ +object GameLogic { + + const val NUM_ROWS = 9 + const val NUM_COLS = 9 + const val NUM_PER_ROW = 5 + + /** Number ranges per column. Col 8 holds 80..90 (11 candidates). */ + private val NUM_IN_COL: List = listOf( + 1..9, 10..19, 20..29, 30..39, 40..49, + 50..59, 60..69, 70..79, 80..90, + ) + + private const val MAX_REJECTION_ATTEMPTS = 200 + + /** + * Generate a 9x9 grid with exactly NUM_PER_ROW filled cells per row AND + * per column. Cell value: 0 = empty, >0 = number. + * + * Soft constraint (rejection-sampled): no row has 3 consecutive filled + * column indices. Hard column-quota invariant always wins if both can't + * be satisfied — matches JS behavior. + */ + fun generateGrid(random: Random = Random.Default): Array { + val cells = Array(NUM_ROWS) { IntArray(NUM_COLS) } + val colsPerRow = pickFilledCols(random) + for (row in 0 until NUM_ROWS) { + for (col in colsPerRow[row]) cells[row][col] = -1 + } + for (col in 0 until NUM_COLS) { + val picked = randomNumbersInCol(NUM_PER_ROW, col, random).toMutableList() + for (row in 0 until NUM_ROWS) { + if (cells[row][col] == -1) { + cells[row][col] = picked.removeAt(0) + } + } + } + return cells + } + + /** Row complete iff it has ≥1 number AND every number is crossed. */ + fun isRowComplete( + grid: Array, + crossed: Array, + row: Int, + ): Boolean { + var hasNumber = false + for (col in 0 until NUM_COLS) { + if (grid[row][col] > 0) { + hasNumber = true + if (!crossed[row][col]) return false + } + } + return hasNumber + } + + /** + * The lone uncrossed number in a row, or null when 0 or ≥2 cells remain. + * Drives the "Chờ N" toast. + */ + fun getWaitingNumber( + grid: Array, + crossed: Array, + row: Int, + ): Int? { + var remaining: Int? = null + for (col in 0 until NUM_COLS) { + if (grid[row][col] > 0 && !crossed[row][col]) { + if (remaining != null) return null + remaining = grid[row][col] + } + } + return remaining + } + + // ----- internal helpers (1:1 with game-logic.js:29-138) ----- + + /** Pick `num` random values from column `col`'s range, sorted ascending. */ + private fun randomNumbersInCol(num: Int, col: Int, random: Random): List { + val candidates = NUM_IN_COL[col].toMutableList() + candidates.shuffle(random) + return candidates.subList(0, num).sorted() + } + + /** True when sorted-ascending column indices contain 3 consecutive integers. */ + private fun hasThreeInARow(cols: List): 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 + } + + /** Every k-sized combination of `arr` (preserves input order). */ + private fun combinations(arr: List, k: Int): List> { + if (k == 0) return listOf(emptyList()) + if (arr.size < k) return emptyList() + val out = mutableListOf>() + 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 + } + + /** + * Single-attempt per-row column picker. Prefers triple-free completions; + * falls back to forced set when no triple-free completion exists so the + * column-quota hard invariant never breaks. + */ + private fun pickFilledColsOnce(random: Random): List> { + val quota = IntArray(NUM_COLS) { NUM_PER_ROW } + val result = mutableListOf>() + for (row in 0 until NUM_ROWS) { + val rowsLeft = NUM_ROWS - row + val forced = mutableListOf() + val candidates = mutableListOf() + for (col in 0 until NUM_COLS) { + when { + quota[col] == rowsLeft -> forced.add(col) + quota[col] > 0 -> candidates.add(col) + } + } + val need = NUM_PER_ROW - forced.size + + val validCompletions = mutableListOf>() + if (!hasThreeInARow(forced)) { + for (combo in combinations(candidates, need)) { + val merged = (forced + combo).sorted() + if (!hasThreeInARow(merged)) validCompletions.add(merged) + } + } + + val selected = if (validCompletions.isNotEmpty()) { + validCompletions[random.nextInt(validCompletions.size)] + } else { + val shuffled = candidates.toMutableList().also { it.shuffle(random) } + (forced + shuffled.subList(0, need)).sorted() + } + + for (col in selected) quota[col]-- + result.add(selected) + } + return result + } + + /** Wraps `pickFilledColsOnce` in rejection sampling. */ + private fun pickFilledCols(random: Random): List> { + var last = pickFilledColsOnce(random) + repeat(MAX_REJECTION_ATTEMPTS) { + if (last.all { !hasThreeInARow(it) }) return last + last = pickFilledColsOnce(random) + } + return last + } +} diff --git a/app/src/main/java/com/miti99/loto/game/VietnameseNumber.kt b/app/src/main/java/com/miti99/loto/game/VietnameseNumber.kt new file mode 100644 index 0000000..2f98e05 --- /dev/null +++ b/app/src/main/java/com/miti99/loto/game/VietnameseNumber.kt @@ -0,0 +1,38 @@ +package com.miti99.loto.game + +/** + * Spoken Vietnamese for lô tô calls (0..90). Honors tonal exceptions: + * - 5 → "năm", but `*5` ≥ 20 → "lăm" (e.g. 25 = "hai mươi lăm") + * - 1 → "một", but `*1` ≥ 20 → "mốt" (e.g. 21 = "hai mươi mốt") + * - 15 → "mười lăm" (special) + * + * Out-of-range values fall back to `n.toString()` — matches the JS source's + * defensive posture (`String(n)` for non-integer / out-of-range), so callers + * never have to guard. + */ +object VietnameseNumber { + + private val ONES = listOf( + "không", "một", "hai", "ba", "bốn", + "năm", "sáu", "bảy", "tám", "chín", + ) + + fun numberToVietnamese(n: Int): String { + if (n < 0 || n > 90) return n.toString() + if (n < 10) return ONES[n] + if (n == 10) return "mười" + if (n < 20) { + val u = n - 10 + return if (u == 5) "mười lăm" else "mười ${ONES[u]}" + } + val t = n / 10 + val u = n % 10 + val tens = "${ONES[t]} mươi" + return when (u) { + 0 -> tens + 1 -> "$tens mốt" + 5 -> "$tens lăm" + else -> "$tens ${ONES[u]}" + } + } +} diff --git a/app/src/main/java/com/miti99/loto/settings/SettingsKeys.kt b/app/src/main/java/com/miti99/loto/settings/SettingsKeys.kt new file mode 100644 index 0000000..6708b8a --- /dev/null +++ b/app/src/main/java/com/miti99/loto/settings/SettingsKeys.kt @@ -0,0 +1,20 @@ +package com.miti99.loto.settings + +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey + +internal object SettingsKeys { + val EMPTY_CELL_COLOR = stringPreferencesKey("empty_cell_color") + val THEME = stringPreferencesKey("theme") + val MODE = stringPreferencesKey("mode") + val AUTO_CALL_ENABLED = booleanPreferencesKey("auto_call_enabled") + val AUTO_CALL_SPEED = intPreferencesKey("auto_call_speed") + val VOICE_ENABLED_MASTER = booleanPreferencesKey("voice_enabled_master") + val VOICE_ENABLED_PLAYER = booleanPreferencesKey("voice_enabled_player") + val VOICE_WAITING_NUMBER = booleanPreferencesKey("voice_waiting_number") + val VOICE = stringPreferencesKey("voice") + + /** Legacy key from web `masterMode: true` → migrate to `mode = "both"`. */ + val LEGACY_MASTER_MODE = booleanPreferencesKey("master_mode") +} diff --git a/app/src/main/java/com/miti99/loto/settings/SettingsRepository.kt b/app/src/main/java/com/miti99/loto/settings/SettingsRepository.kt new file mode 100644 index 0000000..50f3c54 --- /dev/null +++ b/app/src/main/java/com/miti99/loto/settings/SettingsRepository.kt @@ -0,0 +1,141 @@ +package com.miti99.loto.settings + +import androidx.datastore.core.DataMigration +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import com.miti99.loto.settings.SettingsKeys as K +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +/** + * DataStore-backed settings. Reads validate per-field; invalid values fall + * back to defaults (never crashes, matches web `settings-store.svelte.js`). + * + * The legacy `masterMode: true` web key is migrated to `mode = "both"` once + * via [legacyMasterModeMigration] — see [LotoApp] DataStore creation. + */ +class SettingsRepository( + private val dataStore: DataStore, + appScope: CoroutineScope, + private val validVoiceIds: Set = DEFAULT_VOICE_IDS, +) { + + val flow: Flow = dataStore.data.map(::mapToState).distinctUntilChanged() + + /** Hot voice id flow consumed by VoicePlayer. */ + val voiceFlow: StateFlow = flow + .map { it.voice } + .stateIn(appScope, SharingStarted.Eagerly, SettingsState.DEFAULT.voice) + + suspend fun setEmptyCellColor(hex: String) { + if (!HEX6.matches(hex)) return + dataStore.edit { it[K.EMPTY_CELL_COLOR] = hex } + } + + suspend fun setTheme(theme: SettingsState.Theme) = + dataStore.edit { it[K.THEME] = theme.storageKey } + + suspend fun setMode(mode: SettingsState.Mode) = + dataStore.edit { it[K.MODE] = mode.storageKey } + + suspend fun setAutoCallEnabled(enabled: Boolean) = + dataStore.edit { it[K.AUTO_CALL_ENABLED] = enabled } + + suspend fun setAutoCallSpeed(s: Int) { + val v = s.coerceIn(1, 10) + dataStore.edit { it[K.AUTO_CALL_SPEED] = v } + } + + suspend fun setVoiceEnabledMaster(b: Boolean) = + dataStore.edit { it[K.VOICE_ENABLED_MASTER] = b } + + suspend fun setVoiceEnabledPlayer(b: Boolean) = + dataStore.edit { it[K.VOICE_ENABLED_PLAYER] = b } + + suspend fun setVoiceWaitingNumber(b: Boolean) = + dataStore.edit { it[K.VOICE_WAITING_NUMBER] = b } + + suspend fun setVoice(id: String) { + if (id !in validVoiceIds) return + dataStore.edit { it[K.VOICE] = id } + } + + suspend fun reset() { + dataStore.edit { it.clear() } + } + + private fun mapToState(prefs: Preferences): SettingsState { + val d = SettingsState.DEFAULT + return SettingsState( + emptyCellColor = prefs[K.EMPTY_CELL_COLOR] + ?.takeIf { HEX6.matches(it) } ?: d.emptyCellColor, + theme = prefs[K.THEME]?.let(::themeFromStorage) ?: d.theme, + mode = prefs[K.MODE]?.let(::modeFromStorage) ?: d.mode, + autoCallEnabled = prefs[K.AUTO_CALL_ENABLED] ?: d.autoCallEnabled, + autoCallSpeed = prefs[K.AUTO_CALL_SPEED]?.takeIf { it in 1..10 } ?: d.autoCallSpeed, + voiceEnabledMaster = prefs[K.VOICE_ENABLED_MASTER] ?: d.voiceEnabledMaster, + voiceEnabledPlayer = prefs[K.VOICE_ENABLED_PLAYER] ?: d.voiceEnabledPlayer, + voiceWaitingNumber = prefs[K.VOICE_WAITING_NUMBER] ?: d.voiceWaitingNumber, + voice = prefs[K.VOICE]?.takeIf { it in validVoiceIds } ?: d.voice, + ) + } + + companion object { + private val HEX6 = Regex("^#[0-9a-fA-F]{6}$") + private val DEFAULT_VOICE_IDS = setOf("hoai-my", "nam-minh") + + /** + * Legacy migration: web app stored `masterMode: true` for "both" mode. + * Translate once and forget. + */ + val legacyMasterModeMigration: DataMigration = + object : DataMigration { + override suspend fun shouldMigrate(currentData: Preferences): Boolean = + currentData[K.MODE] == null && currentData[K.LEGACY_MASTER_MODE] == true + + override suspend fun migrate(currentData: Preferences): Preferences { + val mut: MutablePreferences = currentData.toMutablePreferences() + mut[K.MODE] = SettingsState.Mode.BOTH.storageKey + mut.remove(K.LEGACY_MASTER_MODE) + return mut + } + + override suspend fun cleanUp() = Unit + } + } +} + +private val SettingsState.Theme.storageKey: String + get() = when (this) { + SettingsState.Theme.AUTO -> "auto" + SettingsState.Theme.LIGHT -> "light" + SettingsState.Theme.DARK -> "dark" + } + +private val SettingsState.Mode.storageKey: String + get() = when (this) { + SettingsState.Mode.PLAYER -> "player" + SettingsState.Mode.MASTER -> "master" + SettingsState.Mode.BOTH -> "both" + } + +private fun themeFromStorage(s: String): SettingsState.Theme? = when (s) { + "auto" -> SettingsState.Theme.AUTO + "light" -> SettingsState.Theme.LIGHT + "dark" -> SettingsState.Theme.DARK + else -> null +} + +private fun modeFromStorage(s: String): SettingsState.Mode? = when (s) { + "player" -> SettingsState.Mode.PLAYER + "master" -> SettingsState.Mode.MASTER + "both" -> SettingsState.Mode.BOTH + else -> null +} diff --git a/app/src/main/java/com/miti99/loto/settings/SettingsState.kt b/app/src/main/java/com/miti99/loto/settings/SettingsState.kt new file mode 100644 index 0000000..ed1b9bf --- /dev/null +++ b/app/src/main/java/com/miti99/loto/settings/SettingsState.kt @@ -0,0 +1,34 @@ +package com.miti99.loto.settings + +/** UI settings state. Mirrors web `settings-store.svelte.js:16-35`. */ +data class SettingsState( + val emptyCellColor: String, + val theme: Theme, + val mode: Mode, + val autoCallEnabled: Boolean, + val autoCallSpeed: Int, + val voiceEnabledMaster: Boolean, + val voiceEnabledPlayer: Boolean, + val voiceWaitingNumber: Boolean, + val voice: String, +) { + enum class Theme { AUTO, LIGHT, DARK } + enum class Mode { PLAYER, MASTER, BOTH } + + companion object { + const val DEFAULT_VOICE = "hoai-my" + const val DEFAULT_EMPTY_CELL_COLOR = "#7030A0" + + val DEFAULT = SettingsState( + emptyCellColor = DEFAULT_EMPTY_CELL_COLOR, + theme = Theme.AUTO, + mode = Mode.PLAYER, + autoCallEnabled = false, + autoCallSpeed = 5, + voiceEnabledMaster = true, + voiceEnabledPlayer = false, + voiceWaitingNumber = false, + voice = DEFAULT_VOICE, + ) + } +} diff --git a/app/src/main/java/com/miti99/loto/state/CallBus.kt b/app/src/main/java/com/miti99/loto/state/CallBus.kt new file mode 100644 index 0000000..252cfa3 --- /dev/null +++ b/app/src/main/java/com/miti99/loto/state/CallBus.kt @@ -0,0 +1,26 @@ +package com.miti99.loto.state + +import java.util.concurrent.atomic.AtomicLong +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * App-scoped event bus. Master broadcasts each draw; PlayerBoard collects in + * "both" mode for auto-tick. Mirrors `tiennm99/loto/src/lib/call-bus.svelte.js`. + * + * `id` monotonically increases so consumers can de-duplicate when the flow + * re-fires on unrelated dependency changes (e.g. settings.mode flipping). + */ +class CallBus { + private val nextId = AtomicLong(1) + private val _events = MutableStateFlow(null) + val events: StateFlow = _events + + fun broadcast(num: Int) { + _events.value = DrawEvent(num, System.currentTimeMillis(), nextId.getAndIncrement()) + } + + fun reset() { + _events.value = null + } +} diff --git a/app/src/main/java/com/miti99/loto/state/DeckState.kt b/app/src/main/java/com/miti99/loto/state/DeckState.kt new file mode 100644 index 0000000..659ec19 --- /dev/null +++ b/app/src/main/java/com/miti99/loto/state/DeckState.kt @@ -0,0 +1,15 @@ +package com.miti99.loto.state + +import kotlinx.serialization.Serializable + +/** Master deck — shuffled remaining + ordered called list. */ +@Serializable +data class DeckState( + val called: List, + val remaining: List, +) { + companion object { + fun fresh(random: kotlin.random.Random = kotlin.random.Random.Default): DeckState = + DeckState(called = emptyList(), remaining = (1..90).shuffled(random)) + } +} diff --git a/app/src/main/java/com/miti99/loto/state/DrawEvent.kt b/app/src/main/java/com/miti99/loto/state/DrawEvent.kt new file mode 100644 index 0000000..62fdd09 --- /dev/null +++ b/app/src/main/java/com/miti99/loto/state/DrawEvent.kt @@ -0,0 +1,4 @@ +package com.miti99.loto.state + +/** A single number-drawn event broadcast from MasterPanel to PlayerBoard. */ +data class DrawEvent(val num: Int, val at: Long, val id: Long) diff --git a/app/src/main/java/com/miti99/loto/state/GameStorage.kt b/app/src/main/java/com/miti99/loto/state/GameStorage.kt new file mode 100644 index 0000000..5b35065 --- /dev/null +++ b/app/src/main/java/com/miti99/loto/state/GameStorage.kt @@ -0,0 +1,59 @@ +package com.miti99.loto.state + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.first +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * DataStore-backed persistence for player grid/crossed and master deck. + * Single shared DataStore (settings file) — JSON-encoded under string keys. + */ +class GameStorage(private val dataStore: DataStore) { + + private val json = Json { ignoreUnknownKeys = true } + + suspend fun savePlayerGrid(grid: List>) = + dataStore.edit { it[KEY_PLAYER_GRID] = json.encodeToString(grid) } + + suspend fun savePlayerCrossed(crossed: List>) = + dataStore.edit { it[KEY_PLAYER_CROSSED] = json.encodeToString(crossed) } + + suspend fun loadPlayer(): Pair>, List>>? { + val prefs = dataStore.data.first() + val gridStr = prefs[KEY_PLAYER_GRID] ?: return null + val crossedStr = prefs[KEY_PLAYER_CROSSED] + val grid = runCatching { json.decodeFromString>>(gridStr) } + .getOrNull()?.takeIf { it.size == 9 && it.all { row -> row.size == 9 } } + ?: return null + val crossed = crossedStr?.let { + runCatching { json.decodeFromString>>(it) } + .getOrNull()?.takeIf { c -> c.size == 9 && c.all { row -> row.size == 9 } } + } ?: List(9) { List(9) { false } } + return grid to crossed + } + + suspend fun saveMasterDeck(deck: DeckState) = + dataStore.edit { it[KEY_MASTER_DECK] = json.encodeToString(deck) } + + suspend fun loadMasterDeck(): DeckState? { + val prefs = dataStore.data.first() + val str = prefs[KEY_MASTER_DECK] ?: return null + return runCatching { json.decodeFromString(str) }.getOrNull() + } + + suspend fun clearAll() = dataStore.edit { + it.remove(KEY_PLAYER_GRID) + it.remove(KEY_PLAYER_CROSSED) + it.remove(KEY_MASTER_DECK) + } + + private companion object { + val KEY_PLAYER_GRID = stringPreferencesKey("player_grid_json") + val KEY_PLAYER_CROSSED = stringPreferencesKey("player_crossed_json") + val KEY_MASTER_DECK = stringPreferencesKey("master_deck_json") + } +} diff --git a/app/src/main/java/com/miti99/loto/state/MasterPanelUiState.kt b/app/src/main/java/com/miti99/loto/state/MasterPanelUiState.kt new file mode 100644 index 0000000..b575bb5 --- /dev/null +++ b/app/src/main/java/com/miti99/loto/state/MasterPanelUiState.kt @@ -0,0 +1,9 @@ +package com.miti99.loto.state + +/** Master panel snapshot. `deck == null` until first newGame(). */ +data class MasterPanelUiState( + val deck: DeckState? = null, + val lastCalled: Int? = null, + val autoRunning: Boolean = false, + val callOrder: Map = emptyMap(), +) diff --git a/app/src/main/java/com/miti99/loto/state/MasterPanelViewModel.kt b/app/src/main/java/com/miti99/loto/state/MasterPanelViewModel.kt new file mode 100644 index 0000000..fc28ec5 --- /dev/null +++ b/app/src/main/java/com/miti99/loto/state/MasterPanelViewModel.kt @@ -0,0 +1,119 @@ +package com.miti99.loto.state + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.miti99.loto.audio.VoicePlayer +import com.miti99.loto.settings.SettingsRepository +import com.miti99.loto.settings.SettingsState +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * Host (quản trò) state machine — port of `MasterPanel.svelte`. + * + * Auto-call coroutine cancels and restarts whenever (autoRunning, + * autoCallEnabled, autoCallSpeed) changes — observed from the settings flow. + */ +class MasterPanelViewModel( + private val settingsRepo: SettingsRepository, + private val callBus: CallBus, + private val voicePlayer: VoicePlayer, + private val storage: GameStorage, +) : ViewModel() { + + private val _state = MutableStateFlow(MasterPanelUiState()) + val state: StateFlow = _state.asStateFlow() + + private var autoJob: Job? = null + @Volatile private var cachedSettings: SettingsState = SettingsState.DEFAULT + + init { + viewModelScope.launch { loadFromStorage() } + viewModelScope.launch { + settingsRepo.flow.collect { s -> + val prev = cachedSettings + cachedSettings = s + // If host disabled auto-call mid-run, force-stop. + if (!s.autoCallEnabled && _state.value.autoRunning) setAutoRunning(false) + // If speed changed mid-run, re-arm at the new cadence. + if (_state.value.autoRunning && prev.autoCallSpeed != s.autoCallSpeed) { + restartAutoJob() + } + } + } + } + + fun newGame() { + voicePlayer.cancel() + setAutoRunning(false) + callBus.reset() + val deck = DeckState.fresh() + _state.value = MasterPanelUiState(deck = deck, lastCalled = null, callOrder = emptyMap()) + viewModelScope.launch { storage.saveMasterDeck(deck) } + } + + fun drawNext() { + val cur = _state.value + val deck = cur.deck ?: return + if (deck.remaining.isEmpty()) return + val next = deck.remaining.first() + val newDeck = deck.copy( + called = deck.called + next, + remaining = deck.remaining.drop(1), + ) + val newOrder = cur.callOrder + (next to (newDeck.called.size)) + _state.update { it.copy(deck = newDeck, lastCalled = next, callOrder = newOrder) } + callBus.broadcast(next) + if (cachedSettings.voiceEnabledMaster) voicePlayer.playNumber(next) + viewModelScope.launch { storage.saveMasterDeck(newDeck) } + } + + fun toggleAuto() { + val deck = _state.value.deck ?: return + if (deck.remaining.isEmpty()) return + setAutoRunning(!_state.value.autoRunning) + } + + private fun setAutoRunning(running: Boolean) { + if (_state.value.autoRunning == running) return + _state.update { it.copy(autoRunning = running) } + if (running) restartAutoJob() else autoJob?.cancel() + } + + private fun restartAutoJob() { + autoJob?.cancel() + autoJob = viewModelScope.launch { + while (isActive) { + val ms = cachedSettings.autoCallSpeed * 1000L + delay(ms) + val deck = _state.value.deck ?: break + if (deck.remaining.isEmpty()) { + setAutoRunning(false) + break + } + drawNext() + } + } + } + + private suspend fun loadFromStorage() { + val deck = storage.loadMasterDeck() ?: return + val order = deck.called.withIndex().associate { (i, n) -> n to (i + 1) } + _state.value = MasterPanelUiState( + deck = deck, + lastCalled = deck.called.lastOrNull(), + callOrder = order, + ) + } + + override fun onCleared() { + autoJob?.cancel() + super.onCleared() + } +} diff --git a/app/src/main/java/com/miti99/loto/state/PlayerBoardUiState.kt b/app/src/main/java/com/miti99/loto/state/PlayerBoardUiState.kt new file mode 100644 index 0000000..fe0456e --- /dev/null +++ b/app/src/main/java/com/miti99/loto/state/PlayerBoardUiState.kt @@ -0,0 +1,21 @@ +package com.miti99.loto.state + +data class BingoEvent(val row1Based: Int, val tier: Int) + +data class WaitingToast(val message: String, val seenAt: Long) + +/** + * Snapshot of the player card surface. `grid == null` means cold-start (show + * the faded preview). `rowComplete` is precomputed in the VM so the UI doesn't + * call isRowComplete inside Compose composition. + */ +data class PlayerBoardUiState( + val grid: List>? = null, + val crossed: List> = emptyList(), + val rowComplete: List = emptyList(), + val bingoEvent: BingoEvent? = null, + val waitingToast: WaitingToast? = null, + val celebratedRows: Set = emptySet(), + val notifiedWaitingRows: Set = emptySet(), + val lastConsumedEventId: Long = 0L, +) diff --git a/app/src/main/java/com/miti99/loto/state/PlayerBoardViewModel.kt b/app/src/main/java/com/miti99/loto/state/PlayerBoardViewModel.kt new file mode 100644 index 0000000..86cb812 --- /dev/null +++ b/app/src/main/java/com/miti99/loto/state/PlayerBoardViewModel.kt @@ -0,0 +1,244 @@ +package com.miti99.loto.state + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.miti99.loto.audio.VoicePlayer +import com.miti99.loto.game.GameLogic +import com.miti99.loto.settings.SettingsRepository +import com.miti99.loto.settings.SettingsState +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * Player card state machine — port of `PlayerBoard.svelte`. + * + * Bingo / waiting idempotency: `celebratedRows` and `notifiedWaitingRows` + * are sticky sets; only `generate()` and `clear()` reset them. Bus events + * are de-duplicated by monotonic `id` to defend against re-emit on settings + * change (per phase 06 risk register). + */ +class PlayerBoardViewModel( + private val settingsRepo: SettingsRepository, + private val callBus: CallBus, + private val voicePlayer: VoicePlayer, + private val storage: GameStorage, +) : ViewModel() { + + private val _state = MutableStateFlow(PlayerBoardUiState()) + val state: StateFlow = _state.asStateFlow() + + private var persistJob: Job? = null + + init { + viewModelScope.launch { loadFromStorage() } + viewModelScope.launch { + combine(callBus.events, settingsRepo.flow.map { it.mode }, ::Pair) + .collect { (event, mode) -> + if (event != null && mode == SettingsState.Mode.BOTH) consumeBusEvent(event) + } + } + } + + fun generate() { + voicePlayer.cancel() + val grid = GameLogic.generateGrid().map { it.toList() } + val crossed = List(grid.size) { List(grid[0].size) { false } } + _state.value = PlayerBoardUiState( + grid = grid, + crossed = crossed, + rowComplete = List(grid.size) { false }, + ) + persistDebounced() + } + + fun clear() { + val cur = _state.value + val grid = cur.grid ?: return + voicePlayer.cancel() + val cleared = List(grid.size) { List(grid[0].size) { false } } + _state.update { + it.copy( + crossed = cleared, + rowComplete = List(grid.size) { false }, + celebratedRows = emptySet(), + notifiedWaitingRows = emptySet(), + bingoEvent = null, + waitingToast = null, + ) + } + persistDebounced() + } + + fun toggleCell(row: Int, col: Int) { + val cur = _state.value + val grid = cur.grid ?: return + if (grid[row][col] <= 0) return + val newCrossed = cur.crossed.mapIndexed { r, line -> + if (r == row) line.mapIndexed { c, v -> if (c == col) !v else v } else line + } + applyCrossedChange(newCrossed) + } + + fun dismissBingo() = _state.update { it.copy(bingoEvent = null) } + + fun dismissWaitingToast() = _state.update { it.copy(waitingToast = null) } + + // ----- internals ----- + + private suspend fun loadFromStorage() { + val (grid, crossed) = storage.loadPlayer() ?: return + val gridArr = toIntArray(grid) + val crossedArr = toBoolArray(crossed) + val rowComplete = List(grid.size) { GameLogic.isRowComplete(gridArr, crossedArr, it) } + val celebrated = rowComplete.withIndex().filter { it.value }.map { it.index }.toSet() + val notifiedWaiting = (grid.indices) + .filter { GameLogic.getWaitingNumber(gridArr, crossedArr, it) != null } + .toSet() + _state.value = PlayerBoardUiState( + grid = grid, + crossed = crossed, + rowComplete = rowComplete, + celebratedRows = celebrated, + notifiedWaitingRows = notifiedWaiting, + ) + } + + /** + * Set-only auto-tick from MasterPanel. Mirrors `PlayerBoard.svelte:136-151`: + * find the first uncrossed match and mark it; never unmark. + */ + private fun consumeBusEvent(event: DrawEvent) { + val cur = _state.value + if (event.id <= cur.lastConsumedEventId) return + val grid = cur.grid ?: return run { + _state.update { it.copy(lastConsumedEventId = event.id) } + } + var hit = false + val newCrossed = cur.crossed.mapIndexed { r, line -> + line.mapIndexed { c, v -> + if (!hit && grid[r][c] == event.num && !v) { hit = true; true } else v + } + } + if (hit) { + applyCrossedChange(newCrossed, lastConsumedEventId = event.id) + } else { + _state.update { it.copy(lastConsumedEventId = event.id) } + } + } + + /** + * Two-pass effect mirror of `PlayerBoard.svelte:91-126`. Pass 1: at most + * one new bingo per state change. Pass 2: waiting toast for every + * non-celebrated row that just hit "1 remaining". + */ + private fun applyCrossedChange( + newCrossed: List>, + lastConsumedEventId: Long? = null, + ) { + val cur = _state.value + val grid = cur.grid ?: return + val gridArr = toIntArray(grid) + val crossedArr = toBoolArray(newCrossed) + + val rowComplete = List(grid.size) { GameLogic.isRowComplete(gridArr, crossedArr, it) } + + var celebrated = cur.celebratedRows + var notifiedWaiting = cur.notifiedWaitingRows + var bingoEvent: BingoEvent? = cur.bingoEvent + var waitingToast: WaitingToast? = cur.waitingToast + + val announce = shouldAnnounce() + + // Pass 1: one new bingo + run { + for (i in grid.indices) { + if (i !in celebrated && rowComplete[i]) { + celebrated = celebrated + i + notifiedWaiting = notifiedWaiting + i + val tier = if (celebrated.size >= 3) 2 else 1 + bingoEvent = BingoEvent(row1Based = i + 1, tier = tier) + if (announce) voicePlayer.playBingo() + return@run + } + } + } + + // Pass 2: waiting toast / clear + for (i in grid.indices) { + if (i in celebrated) continue + val waitNum = GameLogic.getWaitingNumber(gridArr, crossedArr, i) + if (waitNum != null && i !in notifiedWaiting) { + notifiedWaiting = notifiedWaiting + i + waitingToast = WaitingToast("Chờ $waitNum", System.currentTimeMillis()) + if (announce) voicePlayer.playWaiting(waitNum, includeNumberSettings()) + } else if (waitNum == null && i in notifiedWaiting && !rowComplete[i]) { + notifiedWaiting = notifiedWaiting - i + } + } + + _state.update { + it.copy( + crossed = newCrossed, + rowComplete = rowComplete, + celebratedRows = celebrated, + notifiedWaitingRows = notifiedWaiting, + bingoEvent = bingoEvent, + waitingToast = waitingToast, + lastConsumedEventId = lastConsumedEventId ?: it.lastConsumedEventId, + ) + } + persistDebounced() + } + + private fun shouldAnnounce(): Boolean { + // Read settings flow synchronously via a non-blocking peek — we have + // a StateFlow upstream so .replayCache exists. For correctness on + // first-frame, fall back to DEFAULT. + val s = currentSettings() + return s.voiceEnabledPlayer || + (s.voiceEnabledMaster && s.mode == SettingsState.Mode.BOTH) + } + + private fun includeNumberSettings(): Boolean = currentSettings().voiceWaitingNumber + + private fun currentSettings(): SettingsState = + // settingsRepo.voiceFlow is a StateFlow; we don't have a synchronous + // SettingsState mirror. The phase-09 SettingsViewModel exposes one; + // for the VM here, we suspend-read at call sites instead. To keep + // the surface simple, snapshot via a tiny cache updated on every + // collected emission. + cachedSettings ?: SettingsState.DEFAULT + + @Volatile private var cachedSettings: SettingsState? = null + + init { + viewModelScope.launch { + settingsRepo.flow.collect { cachedSettings = it } + } + } + + private fun persistDebounced() { + persistJob?.cancel() + persistJob = viewModelScope.launch { + delay(300) + val s = _state.value + val grid = s.grid ?: return@launch + storage.savePlayerGrid(grid) + storage.savePlayerCrossed(s.crossed) + } + } + + private fun toIntArray(grid: List>): Array = + Array(grid.size) { r -> IntArray(grid[r].size) { c -> grid[r][c] } } + + private fun toBoolArray(crossed: List>): Array = + Array(crossed.size) { r -> BooleanArray(crossed[r].size) { c -> crossed[r][c] } } +} diff --git a/app/src/main/java/com/miti99/loto/state/SettingsViewModel.kt b/app/src/main/java/com/miti99/loto/state/SettingsViewModel.kt new file mode 100644 index 0000000..70ad4f5 --- /dev/null +++ b/app/src/main/java/com/miti99/loto/state/SettingsViewModel.kt @@ -0,0 +1,34 @@ +package com.miti99.loto.state + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.miti99.loto.settings.SettingsRepository +import com.miti99.loto.settings.SettingsState +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** Thin wrapper over [SettingsRepository]. */ +class SettingsViewModel( + private val repo: SettingsRepository, +) : ViewModel() { + + val state: StateFlow = repo.flow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsState.DEFAULT) + + fun setEmptyCellColor(hex: String) = launch { repo.setEmptyCellColor(hex) } + fun setTheme(theme: SettingsState.Theme) = launch { repo.setTheme(theme) } + fun setMode(mode: SettingsState.Mode) = launch { repo.setMode(mode) } + fun setAutoCallEnabled(enabled: Boolean) = launch { repo.setAutoCallEnabled(enabled) } + fun setAutoCallSpeed(s: Int) = launch { repo.setAutoCallSpeed(s) } + fun setVoiceEnabledMaster(b: Boolean) = launch { repo.setVoiceEnabledMaster(b) } + fun setVoiceEnabledPlayer(b: Boolean) = launch { repo.setVoiceEnabledPlayer(b) } + fun setVoiceWaitingNumber(b: Boolean) = launch { repo.setVoiceWaitingNumber(b) } + fun setVoice(id: String) = launch { repo.setVoice(id) } + fun reset() = launch { repo.reset() } + + private fun launch(block: suspend () -> Unit) { + viewModelScope.launch { block() } + } +} diff --git a/app/src/main/java/com/miti99/loto/state/ViewModelFactory.kt b/app/src/main/java/com/miti99/loto/state/ViewModelFactory.kt new file mode 100644 index 0000000..f22cfd9 --- /dev/null +++ b/app/src/main/java/com/miti99/loto/state/ViewModelFactory.kt @@ -0,0 +1,38 @@ +package com.miti99.loto.state + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.miti99.loto.LotoApp + +/** + * Single ViewModelProvider.Factory entry that builds every app VM from the + * [LotoApp] singleton graph. Composables get a VM via: + * + * val vm: PlayerBoardViewModel = viewModel(factory = LotoViewModelFactory(app)) + */ +fun lotoViewModelFactory(app: LotoApp): ViewModelProvider.Factory = viewModelFactory { + val storage = GameStorage(app.lotoDataStoreInternal) + initializer { + PlayerBoardViewModel( + settingsRepo = app.settingsRepo, + callBus = app.callBus, + voicePlayer = app.voicePlayer, + storage = storage, + ) + } + initializer { + MasterPanelViewModel( + settingsRepo = app.settingsRepo, + callBus = app.callBus, + voicePlayer = app.voicePlayer, + storage = storage, + ) + } + initializer { + SettingsViewModel(repo = app.settingsRepo) + } +} + +internal inline fun ViewModelProvider.get(): VM = get(VM::class.java) diff --git a/app/src/main/java/com/miti99/loto/ui/LotoAppRoot.kt b/app/src/main/java/com/miti99/loto/ui/LotoAppRoot.kt new file mode 100644 index 0000000..4259b9b --- /dev/null +++ b/app/src/main/java/com/miti99/loto/ui/LotoAppRoot.kt @@ -0,0 +1,170 @@ +package com.miti99.loto.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.miti99.loto.LotoApp +import com.miti99.loto.R +import com.miti99.loto.audio.VoiceManifest +import com.miti99.loto.settings.SettingsState +import com.miti99.loto.state.MasterPanelViewModel +import com.miti99.loto.state.PlayerBoardViewModel +import com.miti99.loto.state.SettingsViewModel +import com.miti99.loto.state.lotoViewModelFactory +import com.miti99.loto.ui.board.PlayerBoardScreen +import com.miti99.loto.ui.common.LocalEmptyCellColor +import com.miti99.loto.ui.master.MasterPanelScreen +import com.miti99.loto.ui.settings.SettingsSheet +import com.miti99.loto.ui.settings.parseHex +import com.miti99.loto.ui.theme.LotoTheme + +/** + * Root composable. Instantiates all three ViewModels from [lotoViewModelFactory], + * applies the user-selected theme override, provides [LocalEmptyCellColor], and + * conditionally renders [PlayerBoardScreen] / [MasterPanelScreen] based on mode. + */ +@Composable +fun LotoAppRoot() { + val app = LocalContext.current.applicationContext as LotoApp + val factory = remember(app) { lotoViewModelFactory(app) } + + val settingsVm: SettingsViewModel = viewModel(factory = factory) + val playerVm: PlayerBoardViewModel = viewModel(factory = factory) + val masterVm: MasterPanelViewModel = viewModel(factory = factory) + + val settingsState by settingsVm.state.collectAsStateWithLifecycle() + val playerState by playerVm.state.collectAsStateWithLifecycle() + val masterState by masterVm.state.collectAsStateWithLifecycle() + + // Load voice list once at composition entry + val voices = remember(app) { + try { VoiceManifest.load(app) } catch (_: Exception) { emptyList() } + } + + val forcedDark: Boolean? = when (settingsState.theme) { + SettingsState.Theme.AUTO -> null + SettingsState.Theme.LIGHT -> false + SettingsState.Theme.DARK -> true + } + + val emptyCellColor = remember(settingsState.emptyCellColor) { + parseHex(settingsState.emptyCellColor) + } + + LotoTheme(forcedDark = forcedDark) { + CompositionLocalProvider(LocalEmptyCellColor provides emptyCellColor) { + var showSettings by rememberSaveable { mutableStateOf(false) } + + Scaffold( + topBar = { LotoTopBar(onSettingsClick = { showSettings = true }) }, + contentWindowInsets = WindowInsets.safeDrawing, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()), + ) { + Wordmark(modifier = Modifier.padding(vertical = 8.dp)) + + when (settingsState.mode) { + SettingsState.Mode.PLAYER -> { + PlayerBoardScreen( + state = playerState, + emptyCellColor = emptyCellColor, + onGenerate = playerVm::generate, + onClear = playerVm::clear, + onCellClick = playerVm::toggleCell, + onDismissBingo = playerVm::dismissBingo, + onDismissToast = playerVm::dismissWaitingToast, + ) + } + + SettingsState.Mode.MASTER -> { + MasterPanelScreen( + state = masterState, + autoCallEnabled = settingsState.autoCallEnabled, + autoCallSpeed = settingsState.autoCallSpeed, + onNewGame = masterVm::newGame, + onDrawNext = masterVm::drawNext, + onToggleAuto = masterVm::toggleAuto, + ) + } + + SettingsState.Mode.BOTH -> { + // Master panel on top, player board below + Text( + text = stringResource(R.string.mode_master), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 12.dp, top = 4.dp, bottom = 2.dp), + ) + MasterPanelScreen( + state = masterState, + autoCallEnabled = settingsState.autoCallEnabled, + autoCallSpeed = settingsState.autoCallSpeed, + onNewGame = masterVm::newGame, + onDrawNext = masterVm::drawNext, + onToggleAuto = masterVm::toggleAuto, + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) + PlayerBoardScreen( + state = playerState, + emptyCellColor = emptyCellColor, + onGenerate = playerVm::generate, + onClear = playerVm::clear, + onCellClick = playerVm::toggleCell, + onDismissBingo = playerVm::dismissBingo, + onDismissToast = playerVm::dismissWaitingToast, + ) + } + } + } + } + + // Settings bottom sheet — rendered outside Scaffold so it overlays correctly + if (showSettings) { + SettingsSheet( + state = settingsState, + voices = voices, + onSetTheme = settingsVm::setTheme, + onSetMode = settingsVm::setMode, + onSetAutoCallEnabled = settingsVm::setAutoCallEnabled, + onSetAutoCallSpeed = settingsVm::setAutoCallSpeed, + onSetVoiceEnabledMaster = settingsVm::setVoiceEnabledMaster, + onSetVoiceEnabledPlayer = settingsVm::setVoiceEnabledPlayer, + onSetVoiceWaitingNumber = settingsVm::setVoiceWaitingNumber, + onSetVoice = settingsVm::setVoice, + onSetEmptyCellColor = settingsVm::setEmptyCellColor, + onReset = settingsVm::reset, + onDismiss = { showSettings = false }, + ) + } + } + } +} diff --git a/app/src/main/java/com/miti99/loto/ui/LotoTopBar.kt b/app/src/main/java/com/miti99/loto/ui/LotoTopBar.kt new file mode 100644 index 0000000..f776b59 --- /dev/null +++ b/app/src/main/java/com/miti99/loto/ui/LotoTopBar.kt @@ -0,0 +1,31 @@ +package com.miti99.loto.ui + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.miti99.loto.R + +/** + * App bar — empty center title (wordmark lives in the content area below), + * settings gear icon on the trailing end. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LotoTopBar(onSettingsClick: () -> Unit) { + CenterAlignedTopAppBar( + title = { /* wordmark is in the scrollable content below */ }, + actions = { + IconButton(onClick = onSettingsClick) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = stringResource(R.string.settings_title), + ) + } + }, + ) +} diff --git a/app/src/main/java/com/miti99/loto/ui/Wordmark.kt b/app/src/main/java/com/miti99/loto/ui/Wordmark.kt new file mode 100644 index 0000000..5ebf9ac --- /dev/null +++ b/app/src/main/java/com/miti99/loto/ui/Wordmark.kt @@ -0,0 +1,56 @@ +package com.miti99.loto.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import com.miti99.loto.R +import com.miti99.loto.ui.theme.BrandAmberLight +import com.miti99.loto.ui.theme.BrandRoseLight + +/** + * Centered wordmark: italic "Lô tô" in rose→amber→rose gradient, with a small + * "Hội chợ TN1" subtitle beneath. Port of the SvelteKit root heading. + */ +@Composable +fun Wordmark(modifier: Modifier = Modifier) { + val gradient = Brush.horizontalGradient( + colors = listOf(BrandRoseLight, BrandAmberLight, BrandRoseLight), + ) + + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "Lô tô", + style = TextStyle( + brush = gradient, + fontSize = 64.sp, + fontWeight = FontWeight.Black, + fontStyle = FontStyle.Italic, + shadow = Shadow( + color = androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.2f), + offset = Offset(0f, 2f), + blurRadius = 4f, + ), + ), + ) + Text( + text = stringResource(R.string.wordmark_subtitle), + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/app/src/main/java/com/miti99/loto/ui/board/ChoToast.kt b/app/src/main/java/com/miti99/loto/ui/board/ChoToast.kt new file mode 100644 index 0000000..64263b0 --- /dev/null +++ b/app/src/main/java/com/miti99/loto/ui/board/ChoToast.kt @@ -0,0 +1,75 @@ +package com.miti99.loto.ui.board + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.semantics.Role +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.state.WaitingToast +import kotlinx.coroutines.delay + +private val AmberBackground = Color(0xFFF59E0B) // amber-500 +private val AmberText = Color(0xFF1C1917) // stone-900 — high contrast on amber + +/** + * Amber capsule overlay showing "Chờ N" when one number remains in a row. + * + * Auto-dismisses after 5 s. Tap dismisses immediately. + * Caller wraps this inside a Box with absolute positioning over the board. + */ +@Composable +fun ChoToast( + toast: WaitingToast?, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + // Auto-dismiss after 5 seconds, keyed on seenAt so a new toast resets the timer. + LaunchedEffect(toast?.seenAt) { + if (toast != null) { + delay(5_000) + onDismiss() + } + } + + AnimatedVisibility( + visible = toast != null, + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut(), + modifier = modifier, + ) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(AmberBackground) + .clickable(role = Role.Button, onClickLabel = stringResource(R.string.toast_close)) { + onDismiss() + } + .padding(horizontal = 24.dp, vertical = 10.dp), + ) { + Text( + text = toast?.message ?: "", + color = AmberText, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ) + } + } +} diff --git a/app/src/main/java/com/miti99/loto/ui/board/Confetti.kt b/app/src/main/java/com/miti99/loto/ui/board/Confetti.kt new file mode 100644 index 0000000..78009bf --- /dev/null +++ b/app/src/main/java/com/miti99/loto/ui/board/Confetti.kt @@ -0,0 +1,96 @@ +package com.miti99.loto.ui.board + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +private val CONFETTI_EMOJI = listOf("🎊", "✨", "🎉", "🥳") +private const val PARTICLE_COUNT = 12 +private const val DURATION_NS = 3_000_000_000L // 3 seconds + +private data class Particle( + val emoji: String, + val startXFraction: Float, // 0..1 fraction of screen width + val speed: Float, // px per second (screen height fraction) + val rotationSpeed: Float, // degrees per second + val delayNs: Long, // stagger start +) + +/** + * Canvas-free confetti rain. 12 emoji particles fall from the top using + * a single `withFrameNanos` loop — one recompose per frame, no allocations + * inside the loop. + * + * Visible only when `visible == true`. Particles auto-stop after 3 s. + */ +@Composable +fun Confetti( + visible: Boolean, + modifier: Modifier = Modifier, +) { + if (!visible) return + + val screenHeightPx = LocalConfiguration.current.screenHeightDp * 3f // approx density-independent to px ratio + val screenWidthDp = LocalConfiguration.current.screenWidthDp.toFloat() + + val particles = remember { + List(PARTICLE_COUNT) { i -> + Particle( + emoji = CONFETTI_EMOJI[i % CONFETTI_EMOJI.size], + startXFraction = (i.toFloat() / PARTICLE_COUNT) + (((i * 17) % 10) / 100f), + speed = screenHeightPx * (0.25f + (i % 5) * 0.05f), + rotationSpeed = 60f + (i % 7) * 30f, + delayNs = (i * 150_000_000L), // 150 ms stagger + ) + } + } + + var elapsedNs by remember { mutableLongStateOf(0L) } + var running by remember { mutableStateOf(true) } + + LaunchedEffect(visible) { + elapsedNs = 0L + running = true + var lastFrameNs = 0L + while (running && elapsedNs < DURATION_NS) { + withFrameNanos { frameNs -> + if (lastFrameNs == 0L) lastFrameNs = frameNs + elapsedNs += (frameNs - lastFrameNs).coerceAtMost(32_000_000L) // cap at 32ms + lastFrameNs = frameNs + } + } + running = false + } + + Box(modifier = modifier.fillMaxSize()) { + particles.forEach { p -> + val adjustedElapsed = (elapsedNs - p.delayNs).coerceAtLeast(0L) + val progress = adjustedElapsed / 1_000_000_000f // seconds + val yDp = (p.speed * progress / 3f) // approx dp + val xDp = p.startXFraction * screenWidthDp + val rotation = p.rotationSpeed * progress + + Text( + text = p.emoji, + fontSize = 20.sp, + modifier = Modifier + .offset(x = xDp.dp, y = yDp.dp) + .graphicsLayer { rotationZ = rotation % 360f }, + ) + } + } +} diff --git a/app/src/main/java/com/miti99/loto/ui/board/GhostPreview.kt b/app/src/main/java/com/miti99/loto/ui/board/GhostPreview.kt new file mode 100644 index 0000000..4ecb9a1 --- /dev/null +++ b/app/src/main/java/com/miti99/loto/ui/board/GhostPreview.kt @@ -0,0 +1,104 @@ +package com.miti99.loto.ui.board + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +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.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.miti99.loto.R + +// Stub numbers for the ghost 9-col grid — 27 cells total (3 rows × 9 cols). +// Every 3rd position (index % 3 == 0) is treated as a "filled" number slot. +private val GHOST_ROWS = listOf( + listOf(5, 0, 0, 23, 0, 0, 47, 0, 0), + listOf(0, 12, 0, 0, 34, 0, 0, 56, 0), + listOf(0, 0, 18, 0, 0, 41, 0, 0, 72), +) + +/** + * Cold-start placeholder shown when `grid == null`. + * Renders a faded 9-col ghost grid with a prompt instructing the user + * to tap "Tạo bảng mới" to generate a real card. + */ +@Composable +fun GhostPreview( + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Faded ghost grid + Column(modifier = Modifier.alpha(0.3f).fillMaxWidth()) { + GHOST_ROWS.forEach { row -> + Row(modifier = Modifier.fillMaxWidth()) { + row.forEach { num -> + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .weight(1f) + .aspectRatio(3f / 4f) + .background( + if (num > 0) Color(0xFFE2E8F0) + else Color(0xFF7030A0), + ), + ) { + if (num > 0) { + Text( + text = num.toString(), + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + ) + } + } + } + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Prompt text + Text( + text = buildAnnotatedString { + append(stringResource(R.string.ghost_press_to_start_prefix)) + append(" ") + withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)) { + append(stringResource(R.string.ghost_quote_word)) + } + append(" ") + append(stringResource(R.string.ghost_press_to_start_suffix)) + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = stringResource(R.string.ghost_have_fun), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/app/src/main/java/com/miti99/loto/ui/board/KinhModal.kt b/app/src/main/java/com/miti99/loto/ui/board/KinhModal.kt new file mode 100644 index 0000000..2e73f79 --- /dev/null +++ b/app/src/main/java/com/miti99/loto/ui/board/KinhModal.kt @@ -0,0 +1,119 @@ +package com.miti99.loto.ui.board + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.material3.TextButton +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.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +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 androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.miti99.loto.R +import com.miti99.loto.state.BingoEvent +import com.miti99.loto.ui.theme.BrandAmberLight +import com.miti99.loto.ui.theme.BrandRoseLight + +/** + * "Kinh!" bingo celebration dialog. + * + * Shown when [bingoEvent] is non-null. Tier 2 (≥ 3 rows complete) also renders + * [Confetti] behind the dialog via a sibling Box in [PlayerBoardScreen]. + */ +@Composable +fun KinhModal( + bingoEvent: BingoEvent, + onDismiss: () -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background(Color.White) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Gradient title "Kinh!" + val gradientBrush = Brush.horizontalGradient( + colors = listOf(BrandRoseLight, BrandAmberLight, BrandRoseLight), + ) + Text( + text = stringResource(R.string.kinh_title), + style = TextStyle( + brush = gradientBrush, + fontSize = 48.sp, + fontWeight = FontWeight.Black, + fontStyle = FontStyle.Italic, + shadow = Shadow( + color = Color.Black.copy(alpha = 0.15f), + offset = Offset(0f, 2f), + blurRadius = 4f, + ), + ), + ) + + Spacer(modifier = Modifier.height(4.dp)) + + // Row completion message + Text( + text = "${stringResource(R.string.kinh_row_done_prefix)} ${bingoEvent.row1Based} ${stringResource(R.string.kinh_row_done_suffix)}", + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + color = Color(0xFF1F2937), + ) + + Text( + text = stringResource(R.string.kinh_shout_hint), + fontSize = 14.sp, + color = Color(0xFF6B7280), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Primary dismiss button + Button( + onClick = onDismiss, + colors = ButtonDefaults.buttonColors( + containerColor = BrandRoseLight, + ), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.kinh_dismiss), + fontWeight = FontWeight.Bold, + ) + } + + TextButton(onClick = onDismiss) { + Text( + text = stringResource(R.string.kinh_close), + color = Color(0xFF9CA3AF), + ) + } + } + } +} diff --git a/app/src/main/java/com/miti99/loto/ui/board/PlayerBoardGrid.kt b/app/src/main/java/com/miti99/loto/ui/board/PlayerBoardGrid.kt new file mode 100644 index 0000000..2ae1b2d --- /dev/null +++ b/app/src/main/java/com/miti99/loto/ui/board/PlayerBoardGrid.kt @@ -0,0 +1,84 @@ +package com.miti99.loto.ui.board + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +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.sp +import com.miti99.loto.R + +private val SECTION_STARTS = listOf(0, 3, 6) +private val SECTION_LABEL_RES = listOf( + R.string.section_loto, + R.string.section_tn1_2014_2017, + R.string.section_doc_dinh_dien, +) + +/** + * 3 stacked mini-cards (rows 0-2, 3-5, 6-8) with section labels above each. + * + * @param grid 9×9 number grid (non-null — caller gates on null) + * @param crossed 9×9 crossed state + * @param rowComplete 9-element precomputed completeness + * @param emptyCellColor passed through to [PlayerCell] + * @param onCellClick (row, col) callback + */ +@Composable +fun PlayerBoardGrid( + grid: List>, + crossed: List>, + rowComplete: List, + emptyCellColor: Color, + onCellClick: (row: Int, col: Int) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth()) { + SECTION_STARTS.forEachIndexed { sectionIdx, startRow -> + // Section label + Text( + text = stringResource(SECTION_LABEL_RES[sectionIdx]), + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 4.dp, top = if (sectionIdx > 0) 12.dp else 4.dp, bottom = 2.dp), + ) + + // 3-row sub-grid + Column { + for (rowOffset in 0..2) { + val row = startRow + rowOffset + if (row >= grid.size) break + Row(modifier = Modifier.fillMaxWidth()) { + for (col in 0..8) { + val num = grid.getOrNull(row)?.getOrNull(col) ?: 0 + val isCrossed = crossed.getOrNull(row)?.getOrNull(col) ?: false + val complete = rowComplete.getOrNull(row) ?: false + PlayerCell( + num = num, + crossed = isCrossed, + rowComplete = complete, + emptyCellColor = emptyCellColor, + onClick = if (num > 0) { { onCellClick(row, col) } } else null, + modifier = Modifier.weight(1f), + ) + } + } + } + } + + if (sectionIdx < SECTION_STARTS.lastIndex) { + Spacer(modifier = Modifier.height(2.dp)) + } + } + } +} diff --git a/app/src/main/java/com/miti99/loto/ui/board/PlayerBoardScreen.kt b/app/src/main/java/com/miti99/loto/ui/board/PlayerBoardScreen.kt new file mode 100644 index 0000000..5fc0edd --- /dev/null +++ b/app/src/main/java/com/miti99/loto/ui/board/PlayerBoardScreen.kt @@ -0,0 +1,176 @@ +package com.miti99.loto.ui.board + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.miti99.loto.R +import com.miti99.loto.state.PlayerBoardUiState +import com.miti99.loto.ui.theme.BrandIndigoLight +import com.miti99.loto.ui.theme.BrandRoseLight + +/** + * Top-level stateless PlayerBoard composable. + * + * Hosts the 3-section 9×9 grid, cold-start ghost preview, Chờ toast, + * Kinh modal, confetti, and confirm dialogs for generate/clear. + * + * @param emptyCellColor passed from settings; wired in [LotoAppRoot] via + * [com.miti99.loto.ui.common.LocalEmptyCellColor]. + */ +@Composable +fun PlayerBoardScreen( + state: PlayerBoardUiState, + emptyCellColor: Color, + onGenerate: () -> Unit, + onClear: () -> Unit, + onCellClick: (row: Int, col: Int) -> Unit, + onDismissBingo: () -> Unit, + onDismissToast: () -> Unit, + modifier: Modifier = Modifier, +) { + var showGenerateDialog by remember { mutableStateOf(false) } + var showClearDialog by remember { mutableStateOf(false) } + + // Confirm dialogs + if (showGenerateDialog) { + AlertDialog( + onDismissRequest = { showGenerateDialog = false }, + title = { Text(stringResource(R.string.prompt_create_new)) }, + confirmButton = { + TextButton(onClick = { + showGenerateDialog = false + onGenerate() + }) { Text(stringResource(R.string.prompt_yes)) } + }, + dismissButton = { + TextButton(onClick = { showGenerateDialog = false }) { + Text(stringResource(R.string.prompt_no)) + } + }, + ) + } + + if (showClearDialog) { + AlertDialog( + onDismissRequest = { showClearDialog = false }, + title = { Text(stringResource(R.string.prompt_clear_marks)) }, + confirmButton = { + TextButton(onClick = { + showClearDialog = false + onClear() + }) { Text(stringResource(R.string.prompt_yes)) } + }, + dismissButton = { + TextButton(onClick = { showClearDialog = false }) { + Text(stringResource(R.string.prompt_no)) + } + }, + ) + } + + Box( + modifier = modifier + .fillMaxWidth() + .semantics { contentDescription = "Bảng lô tô" }, + ) { + Column(modifier = Modifier.fillMaxWidth()) { + // Action buttons row + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + ) { + // "Tạo bảng mới" — skip dialog if no grid yet + Button( + onClick = { + if (state.grid == null) onGenerate() + else showGenerateDialog = true + }, + colors = ButtonDefaults.buttonColors(containerColor = BrandRoseLight), + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.btn_generate_new)) + } + + // "Xoá đánh dấu" — only shown when a grid exists + if (state.grid != null) { + Spacer(modifier = Modifier.width(8.dp)) + OutlinedButton( + onClick = { + val hasCrossed = state.crossed.any { row -> row.any { it } } + if (hasCrossed) showClearDialog = true else onClear() + }, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = BrandIndigoLight, + ), + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.btn_clear_marks)) + } + } + } + + // Board or ghost preview + if (state.grid == null) { + GhostPreview(modifier = Modifier.padding(top = 8.dp)) + } else { + PlayerBoardGrid( + grid = state.grid, + crossed = state.crossed, + rowComplete = state.rowComplete, + emptyCellColor = emptyCellColor, + onCellClick = onCellClick, + modifier = Modifier.padding(horizontal = 4.dp), + ) + } + } + + // Toast overlay — centered over the board + ChoToast( + toast = state.waitingToast, + onDismiss = onDismissToast, + modifier = Modifier + .align(Alignment.Center) + .padding(top = 56.dp), // clear the button row + ) + + // Confetti for tier-2 bingo (≥ 3 rows complete) + if (state.bingoEvent?.tier == 2) { + Confetti( + visible = true, + modifier = Modifier.fillMaxSize(), + ) + } + } + + // Kinh modal — rendered outside the Box so it is not clipped + if (state.bingoEvent != null) { + KinhModal( + bingoEvent = state.bingoEvent, + onDismiss = onDismissBingo, + ) + } +} diff --git a/app/src/main/java/com/miti99/loto/ui/board/PlayerCell.kt b/app/src/main/java/com/miti99/loto/ui/board/PlayerCell.kt new file mode 100644 index 0000000..f490658 --- /dev/null +++ b/app/src/main/java/com/miti99/loto/ui/board/PlayerCell.kt @@ -0,0 +1,109 @@ +package com.miti99.loto.ui.board + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +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.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +private val BgCrossedComplete = Color(0xFFD1FAE5) // emerald-100 +private val BgCrossedIncomplete = Color(0xFFFEF2F2) // red-50 +private val BgDark = Color(0xFF1E293B) // slate-800 +private val CrossLineColor = Color(0xFFF43F5E) // rose-500 + +/** + * Single player-card cell. + * + * @param num 0 = empty slot (colored background, non-interactive) + * @param crossed whether this number has been marked + * @param rowComplete precomputed from VM; shifts crossed-cell bg to emerald + * @param emptyCellColor background for num==0 cells (from settings) + * @param onClick null when num==0 (cell is non-interactive) + */ +@Composable +fun PlayerCell( + num: Int, + crossed: Boolean, + rowComplete: Boolean, + emptyCellColor: Color, + onClick: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + val haptic = LocalHapticFeedback.current + val dark = isSystemInDarkTheme() + + if (num == 0) { + Box( + modifier = modifier + .aspectRatio(3f / 4f) + .background(emptyCellColor) + .testTag("empty_cell"), + ) { + if (dark) { + Box( + Modifier + .matchParentSize() + .background(Color.Black.copy(alpha = 0.15f)), + ) + } + } + return + } + + val bg = when { + crossed && rowComplete -> BgCrossedComplete + crossed && !rowComplete -> BgCrossedIncomplete + dark -> BgDark + else -> Color.White + } + val desc = "Số $num${if (crossed) ", đã đánh dấu" else ""}" + + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .aspectRatio(3f / 4f) + .background(bg) + .then( + if (onClick != null) Modifier.clickable(role = Role.Button) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onClick() + } else Modifier, + ) + .semantics { contentDescription = desc } + .testTag("player_cell") + .let { m -> + if (crossed) m.drawBehind { + // Diagonal slash from top-start to bottom-end + drawLine( + color = CrossLineColor.copy(alpha = 0.6f), + start = Offset(0f, 0f), + end = Offset(size.width, size.height), + strokeWidth = 2.5f, + ) + } else m + }, + ) { + Text( + text = num.toString(), + fontWeight = FontWeight.SemiBold, + fontSize = 13.sp, + color = if (dark && !crossed) Color(0xFFE2E8F0) else Color.Unspecified, + ) + } +} diff --git a/app/src/main/java/com/miti99/loto/ui/common/LocalEmptyCellColor.kt b/app/src/main/java/com/miti99/loto/ui/common/LocalEmptyCellColor.kt new file mode 100644 index 0000000..06bce17 --- /dev/null +++ b/app/src/main/java/com/miti99/loto/ui/common/LocalEmptyCellColor.kt @@ -0,0 +1,12 @@ +package com.miti99.loto.ui.common + +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.graphics.Color +import com.miti99.loto.ui.theme.EmptyCellPurple + +/** + * Composition local carrying the user-selected empty-cell background color. + * Provided at root in [com.miti99.loto.ui.LotoAppRoot]; consumed by + * [com.miti99.loto.ui.board.PlayerCell] and [com.miti99.loto.ui.master.MasterCell]. + */ +val LocalEmptyCellColor = compositionLocalOf { EmptyCellPurple } diff --git a/app/src/main/java/com/miti99/loto/ui/master/CalledHistory.kt b/app/src/main/java/com/miti99/loto/ui/master/CalledHistory.kt new file mode 100644 index 0000000..c5bd9e6 --- /dev/null +++ b/app/src/main/java/com/miti99/loto/ui/master/CalledHistory.kt @@ -0,0 +1,90 @@ +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.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +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.BrandEmeraldLight +import com.miti99.loto.ui.theme.BrandPinkLight +import com.miti99.loto.ui.theme.CalledCellCream + +/** + * Horizontally scrollable chip row showing the full draw history in order. + * + * Auto-scrolls to the last chip whenever [called] changes. + */ +@Composable +fun CalledHistory( + called: List, + modifier: Modifier = Modifier, +) { + val listState = rememberLazyListState() + + // Scroll to the latest chip on every new draw + LaunchedEffect(called.size) { + if (called.isNotEmpty()) { + listState.animateScrollToItem(called.lastIndex) + } + } + + Column(modifier = modifier) { + Text( + text = stringResource(R.string.master_called_history_label), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 4.dp, bottom = 4.dp), + ) + + LazyRow( + state = listState, + modifier = Modifier.padding(vertical = 4.dp), + ) { + items(called, key = { it }) { num -> + CalledChip(num = num) + } + } + } +} + +@Composable +private fun CalledChip(num: Int) { + val isLow = num <= 49 + val ring = if (isLow) BrandPinkLight else BrandEmeraldLight + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .padding(horizontal = 3.dp) + .size(36.dp) + .clip(CircleShape) + .background(CalledCellCream) + .border(2.dp, ring, CircleShape), + ) { + Text( + text = num.toString(), + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = ring, + ) + } +} diff --git a/app/src/main/java/com/miti99/loto/ui/master/CurrentNumberHero.kt b/app/src/main/java/com/miti99/loto/ui/master/CurrentNumberHero.kt new file mode 100644 index 0000000..a0c81fd --- /dev/null +++ b/app/src/main/java/com/miti99/loto/ui/master/CurrentNumberHero.kt @@ -0,0 +1,84 @@ +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.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +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.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +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.BrandEmeraldLight +import com.miti99.loto.ui.theme.BrandPinkLight +import com.miti99.loto.ui.theme.CalledCellCream + +/** + * Large circle hero showing the most-recently drawn number. + * + * Pink ring for numbers ≤ 49; emerald ring for ≥ 50 (port MasterPanel.svelte:283). + * TalkBack announces each new draw via [LiveRegionMode.Assertive]. + */ +@Composable +fun CurrentNumberHero( + last: Int, + calledCount: Int, + remainingCount: Int, + modifier: Modifier = Modifier, +) { + val isLow = last <= 49 + val ring = if (isLow) BrandPinkLight else BrandEmeraldLight + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier, + ) { + Text( + text = stringResource(R.string.master_current_label), + style = MaterialTheme.typography.labelMedium, + letterSpacing = 4.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(160.dp) + .clip(CircleShape) + .background(CalledCellCream) + .border(8.dp, ring, CircleShape) + .semantics { liveRegion = LiveRegionMode.Assertive }, + ) { + Text( + text = last.toString(), + fontSize = 72.sp, + fontWeight = FontWeight.Black, + color = ring, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "${stringResource(R.string.master_called_count_prefix)} $calledCount/90 · " + + "${stringResource(R.string.master_remaining_prefix)} $remainingCount", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/app/src/main/java/com/miti99/loto/ui/master/MasterBoard.kt b/app/src/main/java/com/miti99/loto/ui/master/MasterBoard.kt new file mode 100644 index 0000000..9c63447 --- /dev/null +++ b/app/src/main/java/com/miti99/loto/ui/master/MasterBoard.kt @@ -0,0 +1,47 @@ +package com.miti99.loto.ui.master + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics + +/** + * 11×9 ones-digit-aligned master board. + * + * @param called ordered list of called numbers (earliest first) + * @param lastCalled the most-recently drawn number, for red-ring highlight + */ +@Composable +fun MasterBoard( + called: List, + lastCalled: Int?, + modifier: Modifier = Modifier, +) { + // Map number → 1-based call order. Recomputed only when `called` changes. + val callOrderMap: Map = remember(called) { + called.withIndex().associate { (i, n) -> n to (i + 1) } + } + + Column( + modifier = modifier + .fillMaxWidth() + .semantics { contentDescription = "Bảng theo dõi số đã xổ" }, + ) { + for (row in MASTER_BOARD) { + Row(modifier = Modifier.fillMaxWidth()) { + for (num in row) { + MasterCell( + num = num, + callOrder = callOrderMap[num], + isLast = num > 0 && num == lastCalled, + modifier = Modifier.weight(1f), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/miti99/loto/ui/master/MasterBoardLayout.kt b/app/src/main/java/com/miti99/loto/ui/master/MasterBoardLayout.kt new file mode 100644 index 0000000..f5d1826 --- /dev/null +++ b/app/src/main/java/com/miti99/loto/ui/master/MasterBoardLayout.kt @@ -0,0 +1,32 @@ +package com.miti99.loto.ui.master + +/** + * 11×9 master board layout. + * + * Row index = ones digit (0..10); column index = tens digit (0..8). + * + * Rules (port of MasterPanel.svelte:10-30): + * row=0, col=0 → 0 (empty corner) + * row=0, col=1..8 → col*10 (10, 20, …, 80) + * row=1..9, col=0 → row (1..9) + * row=1..9, col=1..8 → col*10 + row + * row=10, col=8 → 90 + * row=10, other cols → 0 (empty) + */ +internal val MASTER_BOARD: List> = buildList { + for (row in 0..10) { + add(buildList { + for (col in 0..8) { + val num = when { + row == 10 && col == 8 -> 90 + row == 10 -> 0 + row == 0 && col > 0 -> col * 10 + row == 0 -> 0 + col == 0 -> row + else -> col * 10 + row + } + add(num) + } + }) + } +} diff --git a/app/src/main/java/com/miti99/loto/ui/master/MasterCell.kt b/app/src/main/java/com/miti99/loto/ui/master/MasterCell.kt new file mode 100644 index 0000000..ab2e683 --- /dev/null +++ b/app/src/main/java/com/miti99/loto/ui/master/MasterCell.kt @@ -0,0 +1,107 @@ +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.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +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.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.miti99.loto.ui.common.LocalEmptyCellColor +import com.miti99.loto.ui.theme.BrandEmeraldLight +import com.miti99.loto.ui.theme.BrandPinkLight +import com.miti99.loto.ui.theme.BrandRedLight +import com.miti99.loto.ui.theme.CalledCellCream + +private val UncalledRing = Color(0xFFCBD5E1) // slate-300 +private val UncalledBg = Color(0xFFF8FAFC) // slate-50 +private val UncalledText = Color(0xFF94A3B8) // slate-400 + +/** + * Single token on the 11×9 master board. + * + * @param num 0 = empty slot; >0 = a lô tô number + * @param callOrder 1-based draw order if this number has been called, else null + * @param isLast true for the most-recently drawn number (red ring + scale 1.10) + */ +@Composable +fun MasterCell( + num: Int, + callOrder: Int?, + isLast: Boolean, + modifier: Modifier = Modifier, +) { + if (num == 0) { + Box( + modifier = modifier + .aspectRatio(1f) + .background(LocalEmptyCellColor.current), + ) + return + } + + val isLow = num <= 49 + val isCalled = callOrder != null + + val ringColor = when { + isLast -> BrandRedLight + isCalled -> if (isLow) BrandPinkLight else BrandEmeraldLight + else -> UncalledRing + } + val bgColor = if (isCalled) CalledCellCream else UncalledBg + val textColor = when { + isCalled -> if (isLow) BrandPinkLight else BrandEmeraldLight + else -> UncalledText + } + + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .aspectRatio(1f) + .padding(2.dp), + ) { + // Token circle — scale up + red border when latest + val tokenModifier = Modifier + .fillMaxSize(0.82f) + .let { if (isLast) it.scale(1.10f) else it } + .clip(CircleShape) + .background(bgColor) + .border( + width = if (isLast) 3.dp else 2.dp, + color = ringColor, + shape = CircleShape, + ) + + Box(tokenModifier, contentAlignment = Alignment.Center) { + Text( + text = num.toString(), + color = textColor, + fontWeight = FontWeight.Black, + fontSize = 11.sp, + ) + } + + // Draw-order superscript (top-end corner) + if (callOrder != null) { + Text( + text = callOrder.toString(), + modifier = Modifier + .align(Alignment.TopEnd) + .padding(1.dp), + fontSize = 8.sp, + fontWeight = FontWeight.Bold, + color = textColor, + ) + } + } +} diff --git a/app/src/main/java/com/miti99/loto/ui/master/MasterControls.kt b/app/src/main/java/com/miti99/loto/ui/master/MasterControls.kt new file mode 100644 index 0000000..b50ea84 --- /dev/null +++ b/app/src/main/java/com/miti99/loto/ui/master/MasterControls.kt @@ -0,0 +1,129 @@ +package com.miti99.loto.ui.master + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +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.Modifier +import androidx.compose.ui.graphics.Brush +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 com.miti99.loto.R +import com.miti99.loto.ui.theme.BrandAmberLight +import com.miti99.loto.ui.theme.BrandEmeraldLight +import com.miti99.loto.ui.theme.BrandRedLight +import com.miti99.loto.ui.theme.BrandRoseLight + +/** + * Button row for the master panel. + * + * - "Ván mới" (orange→red gradient) — always visible. + * - When [canDraw]: + * - [autoCallEnabled] true → "Bắt đầu" (emerald) / "Dừng" (red) toggle. + * - [autoCallEnabled] false → "Xổ số" (emerald) draw button. + */ +@Composable +fun MasterControls( + hasState: Boolean, + canDraw: Boolean, + autoCallEnabled: Boolean, + autoRunning: Boolean, + onNewGame: () -> Unit, + onDrawNext: () -> Unit, + onToggleAuto: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + // "Ván mới" — gradient background via Box + transparent Button overlay + Box( + modifier = Modifier + .weight(1f) + .background( + brush = Brush.horizontalGradient(listOf(BrandAmberLight, BrandRoseLight)), + shape = RoundedCornerShape(50), + ), + ) { + Button( + onClick = onNewGame, + colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.btn_new_game), + fontWeight = FontWeight.Bold, + color = Color.White, + ) + } + } + + // Draw / auto toggle — only when there are remaining numbers + if (canDraw) { + Spacer(modifier = Modifier.width(0.dp)) + + if (autoCallEnabled) { + val (label, color) = if (autoRunning) + Pair(stringResource(R.string.btn_auto_stop), BrandRedLight) + else + Pair(stringResource(R.string.btn_auto_start), BrandEmeraldLight) + + Button( + onClick = onToggleAuto, + colors = ButtonDefaults.buttonColors(containerColor = color), + modifier = Modifier.weight(1f), + ) { + Text(text = label, fontWeight = FontWeight.Bold) + } + } else { + Button( + onClick = onDrawNext, + colors = ButtonDefaults.buttonColors(containerColor = BrandEmeraldLight), + modifier = Modifier.weight(1f), + ) { + Text( + text = stringResource(R.string.btn_draw_next), + fontWeight = FontWeight.Bold, + ) + } + } + } + } +} + +/** Small chip showing "Tự động: Ns/số" when auto-call is running. */ +@Composable +fun AutoCallChip( + speedSeconds: Int, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .padding(horizontal = 8.dp, vertical = 2.dp) + .background( + color = BrandEmeraldLight.copy(alpha = 0.15f), + shape = RoundedCornerShape(50), + ) + .padding(horizontal = 12.dp, vertical = 4.dp), + ) { + Text( + text = stringResource(R.string.master_auto_label_format, speedSeconds), + color = BrandEmeraldLight, + fontWeight = FontWeight.SemiBold, + ) + } +} diff --git a/app/src/main/java/com/miti99/loto/ui/master/MasterPanelScreen.kt b/app/src/main/java/com/miti99/loto/ui/master/MasterPanelScreen.kt new file mode 100644 index 0000000..034bdd6 --- /dev/null +++ b/app/src/main/java/com/miti99/loto/ui/master/MasterPanelScreen.kt @@ -0,0 +1,154 @@ +package com.miti99.loto.ui.master + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.miti99.loto.R +import com.miti99.loto.state.MasterPanelUiState + +/** + * Stateless master-panel (quản trò) screen. + * + * Auto-scroll: hero scrolls into view on each new draw, gated by [hasInteracted] + * so a cold-load with persisted state does NOT yank the screen. + * + * Port of MasterPanel.svelte:120-131. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun MasterPanelScreen( + state: MasterPanelUiState, + autoCallEnabled: Boolean, + autoCallSpeed: Int, + onNewGame: () -> Unit, + onDrawNext: () -> Unit, + onToggleAuto: () -> Unit, + modifier: Modifier = Modifier, +) { + var showNewGameDialog by remember { mutableStateOf(false) } + // Only auto-scroll after first explicit user action in this session + var hasInteracted by rememberSaveable { mutableStateOf(false) } + val heroRequester = remember { BringIntoViewRequester() } + + LaunchedEffect(state.lastCalled) { + if (state.lastCalled != null && hasInteracted) { + heroRequester.bringIntoView() + } + } + + // New-game confirmation dialog + if (showNewGameDialog) { + AlertDialog( + onDismissRequest = { showNewGameDialog = false }, + title = { Text(stringResource(R.string.prompt_new_game)) }, + confirmButton = { + TextButton(onClick = { + showNewGameDialog = false + hasInteracted = true + onNewGame() + }) { Text(stringResource(R.string.prompt_yes)) } + }, + dismissButton = { + TextButton(onClick = { showNewGameDialog = false }) { + Text(stringResource(R.string.prompt_no)) + } + }, + ) + } + + Column(modifier = modifier.fillMaxWidth()) { + MasterControls( + hasState = state.deck != null, + canDraw = state.deck?.remaining?.isNotEmpty() ?: false, + autoCallEnabled = autoCallEnabled, + autoRunning = state.autoRunning, + onNewGame = { + if (state.deck != null) showNewGameDialog = true + else { hasInteracted = true; onNewGame() } + }, + onDrawNext = { hasInteracted = true; onDrawNext() }, + onToggleAuto = { hasInteracted = true; onToggleAuto() }, + ) + + // Auto-call speed chip + if (autoCallEnabled && state.deck?.remaining?.isNotEmpty() == true) { + AutoCallChip( + speedSeconds = autoCallSpeed, + modifier = Modifier.padding(start = 8.dp, bottom = 4.dp), + ) + } + + if (state.deck != null) { + // Hero number + if (state.lastCalled != null) { + Spacer(modifier = Modifier.height(8.dp)) + CurrentNumberHero( + last = state.lastCalled, + calledCount = state.deck.called.size, + remainingCount = state.deck.remaining.size, + modifier = Modifier + .fillMaxWidth() + .bringIntoViewRequester(heroRequester), + ) + } else { + // Pre-game hint after new game started but nothing drawn yet + Text( + text = stringResource(R.string.master_empty_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(vertical = 24.dp), + ) + } + + // Called history chip row + if (state.deck.called.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + CalledHistory( + called = state.deck.called, + modifier = Modifier.padding(horizontal = 8.dp), + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // 11×9 master board + MasterBoard( + called = state.deck.called, + lastCalled = state.lastCalled, + modifier = Modifier.padding(horizontal = 4.dp), + ) + } else { + // No game started yet + Text( + text = stringResource(R.string.master_empty_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(vertical = 32.dp), + ) + } + } +} diff --git a/app/src/main/java/com/miti99/loto/ui/settings/AutoCallControls.kt b/app/src/main/java/com/miti99/loto/ui/settings/AutoCallControls.kt new file mode 100644 index 0000000..19af0f0 --- /dev/null +++ b/app/src/main/java/com/miti99/loto/ui/settings/AutoCallControls.kt @@ -0,0 +1,69 @@ +package com.miti99.loto.ui.settings + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.miti99.loto.R + +/** + * Auto-call section: a switch to enable/disable, and a speed slider (1–10 s) + * that only appears when the switch is on. + * + * Slider uses `steps = 8` because Material 3 counts intermediate stops + * between the two endpoints — 8 steps gives 10 discrete values (1..10). + */ +@Composable +internal fun AutoCallControls( + enabled: Boolean, + speedSeconds: Int, + onSetEnabled: (Boolean) -> Unit, + onSetSpeed: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.auto_call_enable), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Switch( + checked = enabled, + onCheckedChange = onSetEnabled, + ) + } + + AnimatedVisibility(visible = enabled) { + Column { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.auto_call_speed_format, speedSeconds), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Slider( + value = speedSeconds.toFloat(), + onValueChange = { onSetSpeed(it.toInt()) }, + valueRange = 1f..10f, + steps = 8, // 8 intermediate stops → 10 discrete positions + modifier = Modifier.fillMaxWidth(), + ) + } + } + } +} diff --git a/app/src/main/java/com/miti99/loto/ui/settings/ColorSwatchTile.kt b/app/src/main/java/com/miti99/loto/ui/settings/ColorSwatchTile.kt new file mode 100644 index 0000000..774a5e1 --- /dev/null +++ b/app/src/main/java/com/miti99/loto/ui/settings/ColorSwatchTile.kt @@ -0,0 +1,65 @@ +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.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import com.miti99.loto.ui.theme.BrandEmeraldLight + +/** + * 40 dp filled circle swatch. Shows a double-ring (white inner + emerald outer) + * when [selected]. + */ +@Composable +internal fun ColorSwatchTile( + hex: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val fillColor = parseHex(hex) + + Box( + modifier = modifier + .size(40.dp) + .clip(CircleShape) + .background(fillColor) + .then( + if (selected) Modifier + .border(2.dp, Color.White, CircleShape) + .border(4.dp, BrandEmeraldLight, CircleShape) + else Modifier.border(1.dp, Color.White.copy(alpha = 0.4f), CircleShape) + ) + .clickable(role = Role.RadioButton, onClick = onClick), + ) +} + +/** "Tùy chỉnh" tile — shown as a gradient or checkerboard placeholder. */ +@Composable +internal fun CustomTile( + active: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + // Use a simple dark-bordered circle as the custom trigger tile + Box( + modifier = modifier + .size(40.dp) + .clip(CircleShape) + .background(if (active) BrandEmeraldLight.copy(alpha = 0.2f) else Color(0xFFF1F5F9)) + .border( + width = if (active) 2.dp else 1.dp, + color = if (active) BrandEmeraldLight else Color(0xFFCBD5E1), + shape = CircleShape, + ) + .clickable(role = Role.Button, onClick = onClick), + ) +} diff --git a/app/src/main/java/com/miti99/loto/ui/settings/EmptyCellColorPicker.kt b/app/src/main/java/com/miti99/loto/ui/settings/EmptyCellColorPicker.kt new file mode 100644 index 0000000..1a90219 --- /dev/null +++ b/app/src/main/java/com/miti99/loto/ui/settings/EmptyCellColorPicker.kt @@ -0,0 +1,97 @@ +package com.miti99.loto.ui.settings + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +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.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.miti99.loto.R + +/** + * Empty-cell color picker with 10 Excel-preset swatches + custom RGB sliders. + * + * Live preview bar reflects [currentHex] immediately. The custom tile expands + * [RgbSliderRow] inline; selecting a swatch collapses it. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun EmptyCellColorPicker( + currentHex: String, + onSelect: (String) -> Unit, + modifier: Modifier = Modifier, +) { + var customExpanded by remember { mutableStateOf(false) } + + Column(modifier = modifier) { + // Live preview bar + Box( + modifier = Modifier + .fillMaxWidth() + .height(40.dp) + .clip(RoundedCornerShape(8.dp)) + .background(parseHex(currentHex)), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // 5-per-row swatch grid + custom tile + FlowRow( + maxItemsInEachRow = 5, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + EXCEL_PRESETS.forEach { hex -> + ColorSwatchTile( + hex = hex, + selected = currentHex.equals(hex, ignoreCase = true), + onClick = { + customExpanded = false + onSelect(hex) + }, + ) + } + CustomTile( + active = customExpanded, + onClick = { customExpanded = !customExpanded }, + ) + } + + // Inline "Tùy chỉnh" label + if (customExpanded) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.empty_cell_custom), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 2.dp), + ) + } + + // RGB slider panel + AnimatedVisibility(visible = customExpanded) { + RgbSliderRow( + currentHex = currentHex, + onSelect = onSelect, + modifier = Modifier.padding(top = 8.dp), + ) + } + } +} diff --git a/app/src/main/java/com/miti99/loto/ui/settings/ExcelPresets.kt b/app/src/main/java/com/miti99/loto/ui/settings/ExcelPresets.kt new file mode 100644 index 0000000..2e4a8c7 --- /dev/null +++ b/app/src/main/java/com/miti99/loto/ui/settings/ExcelPresets.kt @@ -0,0 +1,48 @@ +package com.miti99.loto.ui.settings + +import androidx.compose.ui.graphics.Color + +/** + * Excel "Standard Colors" palette — 10 swatches matching the web app's preset list + * from SettingsButton.svelte. Default empty-cell color is the last entry (purple). + */ +internal val EXCEL_PRESETS = listOf( + "#C00000", // dark red + "#FF0000", // red + "#FFC000", // orange + "#FFFF00", // yellow + "#92D050", // light green + "#00B050", // green + "#00B0F0", // light blue + "#0070C0", // blue + "#002060", // dark blue + "#7030A0", // purple (default) +) + +/** + * Parse a "#RRGGBB" hex string to a Compose [Color]. + * The `#` prefix is optional. Returns [Color.Gray] as a safe fallback on parse error. + */ +internal fun parseHex(hex: String): Color { + return try { + val clean = hex.trimStart('#') + require(clean.length == 6) + val r = clean.substring(0, 2).toInt(16) + val g = clean.substring(2, 4).toInt(16) + val b = clean.substring(4, 6).toInt(16) + Color(r, g, b) + } catch (_: Exception) { + Color.Gray + } +} + +/** + * Convert a Compose [Color] to a "#RRGGBB" hex string. + * Ignores the alpha channel. + */ +internal fun colorToHex(color: Color): String { + val r = (color.red * 255).toInt().coerceIn(0, 255) + val g = (color.green * 255).toInt().coerceIn(0, 255) + val b = (color.blue * 255).toInt().coerceIn(0, 255) + return "#%02X%02X%02X".format(r, g, b) +} diff --git a/app/src/main/java/com/miti99/loto/ui/settings/RgbSliderRow.kt b/app/src/main/java/com/miti99/loto/ui/settings/RgbSliderRow.kt new file mode 100644 index 0000000..aa35fd6 --- /dev/null +++ b/app/src/main/java/com/miti99/loto/ui/settings/RgbSliderRow.kt @@ -0,0 +1,103 @@ +package com.miti99.loto.ui.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.miti99.loto.R +import kotlinx.coroutines.delay + +/** + * Three R/G/B sliders (0-255 each) for custom empty-cell color selection. + * + * Writes are debounced 150 ms to avoid flooding the DataStore with every + * frame while the user drags (per phase-09 risk register). + * + * @param currentHex hex string representing the current color (e.g. "#7030A0") + * @param onSelect called with new "#RRGGBB" string after debounce + */ +@Composable +internal fun RgbSliderRow( + currentHex: String, + onSelect: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val parsed = remember(currentHex) { parseHex(currentHex) } + + var r by remember(currentHex) { mutableFloatStateOf((parsed.red * 255f)) } + var g by remember(currentHex) { mutableFloatStateOf((parsed.green * 255f)) } + var b by remember(currentHex) { mutableFloatStateOf((parsed.blue * 255f)) } + + // Debounce: only call onSelect after user stops dragging for 150 ms + val liveHex = "#%02X%02X%02X".format(r.toInt(), g.toInt(), b.toInt()) + LaunchedEffect(liveHex) { + delay(150) + if (!liveHex.equals(currentHex, ignoreCase = true)) { + onSelect(liveHex) + } + } + + Column(modifier = modifier.fillMaxWidth()) { + SliderRow( + label = stringResource(R.string.empty_cell_r), + value = r, + onValueChange = { r = it }, + ) + SliderRow( + label = stringResource(R.string.empty_cell_g), + value = g, + onValueChange = { g = it }, + ) + SliderRow( + label = stringResource(R.string.empty_cell_b), + value = b, + onValueChange = { b = it }, + ) + } +} + +@Composable +private fun SliderRow( + label: String, + value: Float, + onValueChange: (Float) -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.width(16.dp), + ) + Spacer(Modifier.width(8.dp)) + Slider( + value = value, + onValueChange = onValueChange, + valueRange = 0f..255f, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = value.toInt().toString(), + fontSize = 12.sp, + modifier = Modifier.width(28.dp), + ) + } +} diff --git a/app/src/main/java/com/miti99/loto/ui/settings/SettingsSheet.kt b/app/src/main/java/com/miti99/loto/ui/settings/SettingsSheet.kt new file mode 100644 index 0000000..e242f15 --- /dev/null +++ b/app/src/main/java/com/miti99/loto/ui/settings/SettingsSheet.kt @@ -0,0 +1,154 @@ +package com.miti99.loto.ui.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.miti99.loto.R +import com.miti99.loto.audio.VoiceEntry +import com.miti99.loto.settings.SettingsState + +/** + * Full-app settings exposed as a [ModalBottomSheet]. + * + * All state is read from [state]; every change fires the corresponding setter. + * Opened/dismissed via [onDismiss] — caller owns the visibility flag. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsSheet( + state: SettingsState, + voices: List, + onSetTheme: (SettingsState.Theme) -> Unit, + onSetMode: (SettingsState.Mode) -> Unit, + onSetAutoCallEnabled: (Boolean) -> Unit, + onSetAutoCallSpeed: (Int) -> Unit, + onSetVoiceEnabledMaster: (Boolean) -> Unit, + onSetVoiceEnabledPlayer: (Boolean) -> Unit, + onSetVoiceWaitingNumber: (Boolean) -> Unit, + onSetVoice: (String) -> Unit, + onSetEmptyCellColor: (String) -> Unit, + onReset: () -> Unit, + onDismiss: () -> Unit, +) { + ModalBottomSheet(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = stringResource(R.string.settings_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + + Spacer(Modifier.height(16.dp)) + + // 1. Theme picker + SettingsSection(title = stringResource(R.string.settings_theme)) { + ThemePicker( + current = state.theme, + onChange = onSetTheme, + modifier = Modifier.fillMaxWidth(), + ) + } + + SectionDivider() + + // 2. Mode picker + SettingsSection(title = stringResource(R.string.settings_mode)) { + ModePicker( + current = state.mode, + onChange = onSetMode, + modifier = Modifier.fillMaxWidth(), + ) + } + + // 3. Auto-call (only relevant when mode != PLAYER) + if (state.mode != SettingsState.Mode.PLAYER) { + SectionDivider() + SettingsSection(title = stringResource(R.string.settings_auto_call)) { + AutoCallControls( + enabled = state.autoCallEnabled, + speedSeconds = state.autoCallSpeed, + onSetEnabled = onSetAutoCallEnabled, + onSetSpeed = onSetAutoCallSpeed, + ) + } + } + + SectionDivider() + + // 4. Voice toggles + picker + SettingsSection(title = stringResource(R.string.settings_voice)) { + VoiceToggles( + state = state, + onSetVoiceEnabledMaster = onSetVoiceEnabledMaster, + onSetVoiceEnabledPlayer = onSetVoiceEnabledPlayer, + onSetVoiceWaitingNumber = onSetVoiceWaitingNumber, + ) + Spacer(Modifier.height(8.dp)) + VoicePicker( + voices = voices, + currentVoiceId = state.voice, + onSelect = onSetVoice, + ) + } + + SectionDivider() + + // 5. Empty-cell color + SettingsSection(title = stringResource(R.string.settings_empty_cell_color)) { + EmptyCellColorPicker( + currentHex = state.emptyCellColor, + onSelect = onSetEmptyCellColor, + ) + } + + Spacer(Modifier.height(24.dp)) + + // Reset + done buttons + TextButton(onClick = onReset) { + Text(stringResource(R.string.settings_reset)) + } + + Spacer(Modifier.height(16.dp)) + } + } +} + +@Composable +private fun SettingsSection( + title: String, + content: @Composable () -> Unit, +) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 8.dp), + ) + content() + Spacer(Modifier.height(8.dp)) +} + +@Composable +private fun SectionDivider() { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) +} diff --git a/app/src/main/java/com/miti99/loto/ui/settings/ThemeModePickers.kt b/app/src/main/java/com/miti99/loto/ui/settings/ThemeModePickers.kt new file mode 100644 index 0000000..9637902 --- /dev/null +++ b/app/src/main/java/com/miti99/loto/ui/settings/ThemeModePickers.kt @@ -0,0 +1,72 @@ +package com.miti99.loto.ui.settings + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.miti99.loto.R +import com.miti99.loto.settings.SettingsState + +private fun themeLabel(theme: SettingsState.Theme): Int = when (theme) { + SettingsState.Theme.AUTO -> R.string.theme_auto + SettingsState.Theme.LIGHT -> R.string.theme_light + SettingsState.Theme.DARK -> R.string.theme_dark +} + +private fun modeLabel(mode: SettingsState.Mode): Int = when (mode) { + SettingsState.Mode.PLAYER -> R.string.mode_player + SettingsState.Mode.MASTER -> R.string.mode_master + SettingsState.Mode.BOTH -> R.string.mode_both +} + +/** + * 3-segment row for theme selection (Auto / Sáng / Tối). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ThemePicker( + current: SettingsState.Theme, + onChange: (SettingsState.Theme) -> Unit, + modifier: Modifier = Modifier, +) { + val themes = SettingsState.Theme.values() + SingleChoiceSegmentedButtonRow(modifier = modifier) { + themes.forEachIndexed { i, theme -> + SegmentedButton( + selected = current == theme, + onClick = { onChange(theme) }, + shape = SegmentedButtonDefaults.itemShape(i, themes.size), + ) { + Text(stringResource(themeLabel(theme))) + } + } + } +} + +/** + * 3-segment row for mode selection (Người chơi / Quản trò / Cả hai). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ModePicker( + current: SettingsState.Mode, + onChange: (SettingsState.Mode) -> Unit, + modifier: Modifier = Modifier, +) { + val modes = SettingsState.Mode.values() + SingleChoiceSegmentedButtonRow(modifier = modifier) { + modes.forEachIndexed { i, mode -> + SegmentedButton( + selected = current == mode, + onClick = { onChange(mode) }, + shape = SegmentedButtonDefaults.itemShape(i, modes.size), + ) { + Text(stringResource(modeLabel(mode))) + } + } + } +} diff --git a/app/src/main/java/com/miti99/loto/ui/settings/VoicePicker.kt b/app/src/main/java/com/miti99/loto/ui/settings/VoicePicker.kt new file mode 100644 index 0000000..6b12958 --- /dev/null +++ b/app/src/main/java/com/miti99/loto/ui/settings/VoicePicker.kt @@ -0,0 +1,59 @@ +package com.miti99.loto.ui.settings + +import androidx.compose.foundation.clickable +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.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.miti99.loto.R +import com.miti99.loto.audio.VoiceEntry + +/** + * Radio-button list of available voices loaded from the asset manifest. + * Hidden if [voices] is empty (manifest failed to load). + */ +@Composable +internal fun VoicePicker( + voices: List, + currentVoiceId: String, + onSelect: (String) -> Unit, + modifier: Modifier = Modifier, +) { + if (voices.isEmpty()) return + + Column(modifier = modifier) { + Text( + text = stringResource(R.string.voice_picker_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 4.dp), + ) + voices.forEach { voice -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect(voice.id) } + .padding(vertical = 4.dp), + ) { + RadioButton( + selected = currentVoiceId == voice.id, + onClick = { onSelect(voice.id) }, + ) + Text( + text = voice.label, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } +} diff --git a/app/src/main/java/com/miti99/loto/ui/settings/VoiceToggles.kt b/app/src/main/java/com/miti99/loto/ui/settings/VoiceToggles.kt new file mode 100644 index 0000000..dc8705a --- /dev/null +++ b/app/src/main/java/com/miti99/loto/ui/settings/VoiceToggles.kt @@ -0,0 +1,72 @@ +package com.miti99.loto.ui.settings + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.miti99.loto.R +import com.miti99.loto.settings.SettingsState + +/** + * Three voice-announcement toggle rows. + * + * The "đọc số khi Chờ" switch is only visible when [state.voiceEnabledPlayer] is on + * (matching web's conditional render at SettingsButton.svelte). + */ +@Composable +internal fun VoiceToggles( + state: SettingsState, + onSetVoiceEnabledMaster: (Boolean) -> Unit, + onSetVoiceEnabledPlayer: (Boolean) -> Unit, + onSetVoiceWaitingNumber: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + SwitchRow( + label = stringResource(R.string.voice_enabled_master), + checked = state.voiceEnabledMaster, + onCheckedChange = onSetVoiceEnabledMaster, + ) + SwitchRow( + label = stringResource(R.string.voice_enabled_player), + checked = state.voiceEnabledPlayer, + onCheckedChange = onSetVoiceEnabledPlayer, + ) + AnimatedVisibility(visible = state.voiceEnabledPlayer) { + SwitchRow( + label = stringResource(R.string.voice_waiting_number), + checked = state.voiceWaitingNumber, + onCheckedChange = onSetVoiceWaitingNumber, + ) + } + } +} + +@Composable +private fun SwitchRow( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + ) + } +} diff --git a/app/src/main/java/com/miti99/loto/ui/theme/Color.kt b/app/src/main/java/com/miti99/loto/ui/theme/Color.kt new file mode 100644 index 0000000..dc58e2c --- /dev/null +++ b/app/src/main/java/com/miti99/loto/ui/theme/Color.kt @@ -0,0 +1,49 @@ +package com.miti99.loto.ui.theme + +import androidx.compose.ui.graphics.Color + +// Brand palette — sourced from the SvelteKit app's Tailwind tokens. +// Light/Dark variants are the same family one shade apart so the wordmark +// gradient and game tokens read consistently in either mode. + +// Rose — wordmark start, generate button, accent action. +val BrandRoseLight = Color(0xFFF43F5E) // rose-500 +val BrandRoseDark = Color(0xFFFB7185) // rose-400 + +// Amber — wordmark end, called-cell cream fill. +val BrandAmberLight = Color(0xFFF59E0B) // amber-500 +val BrandAmberDark = Color(0xFFFBBF24) // amber-400 + +// Emerald — called numbers ≥ 50, draw button, completed-row cells. +val BrandEmeraldLight = Color(0xFF10B981) // emerald-500 +val BrandEmeraldDark = Color(0xFF34D399) // emerald-400 + +// Pink — called numbers ≤ 49, modal subtitle. +val BrandPinkLight = Color(0xFFEC4899) // pink-500 +val BrandPinkDark = Color(0xFFF472B6) // pink-400 + +// Purple — secondary brand (legacy Excel purple — also default empty cell). +val BrandPurpleLight = Color(0xFF8B5CF6) // violet-500 +val BrandPurpleDark = Color(0xFFA78BFA) // violet-400 + +// Red — last-draw ring, marked-but-incomplete cell text. +val BrandRedLight = Color(0xFFEF4444) // red-500 +val BrandRedDark = Color(0xFFF87171) // red-400 + +// Indigo — player accent (settings highlight, "Tạo bảng mới" gradient). +val BrandIndigoLight = Color(0xFF6366F1) // indigo-500 +val BrandIndigoDark = Color(0xFF818CF8) // indigo-400 + +// Empty cell legacy default (Excel "Standard Color: Purple"). User-overridable +// via settings; mirrors web app's `--empty-cell-bg` CSS variable. +val EmptyCellPurple = Color(0xFF7030A0) + +// Surfaces +val SurfaceLight = Color(0xFFFAFAFA) +val SurfaceDark = Color(0xFF1E293B) // slate-800 +val BackgroundLight = Color(0xFFFFFFFF) +val BackgroundDark = Color(0xFF0F172A) // slate-900 + +// Cell tokens +val CalledCellCream = Color(0xFFFFFBEB) // amber-50 — fill for called number tokens +val CalledCellCreamDark = Color(0xFFFEF3C7) // amber-100 — slightly warmer in dark diff --git a/app/src/main/java/com/miti99/loto/ui/theme/Theme.kt b/app/src/main/java/com/miti99/loto/ui/theme/Theme.kt new file mode 100644 index 0000000..3894bbc --- /dev/null +++ b/app/src/main/java/com/miti99/loto/ui/theme/Theme.kt @@ -0,0 +1,62 @@ +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 + +private val LotoLightColorScheme = lightColorScheme( + primary = BrandRoseLight, + onPrimary = BackgroundLight, + secondary = BrandAmberLight, + onSecondary = BackgroundLight, + tertiary = BrandEmeraldLight, + onTertiary = BackgroundLight, + background = BackgroundLight, + onBackground = Color0F172A, + surface = SurfaceLight, + onSurface = Color0F172A, + error = BrandRedLight, +) + +private val LotoDarkColorScheme = darkColorScheme( + primary = BrandRoseDark, + onPrimary = BackgroundDark, + secondary = BrandAmberDark, + onSecondary = BackgroundDark, + tertiary = BrandEmeraldDark, + onTertiary = BackgroundDark, + background = BackgroundDark, + onBackground = ColorE2E8F0, + surface = SurfaceDark, + onSurface = ColorE2E8F0, + error = BrandRedDark, +) + +/** + * Theme entry point. + * + * @param forcedDark when non-null overrides the system dark-mode preference. + * The Settings layer (phase 09) feeds the user's `auto|light|dark` choice + * into this — `null` means follow the system. + */ +@Composable +fun LotoTheme( + forcedDark: Boolean? = null, + content: @Composable () -> Unit, +) { + val darkTheme = forcedDark ?: isSystemInDarkTheme() + val colorScheme = if (darkTheme) LotoDarkColorScheme else LotoLightColorScheme + + MaterialTheme( + colorScheme = colorScheme, + typography = LotoTypography, + content = content, + ) +} + +// Local color tokens used only inside the theme file — kept private so they +// don't pollute the brand palette namespace exported from Color.kt. +private val Color0F172A = androidx.compose.ui.graphics.Color(0xFF0F172A) +private val ColorE2E8F0 = androidx.compose.ui.graphics.Color(0xFFE2E8F0) diff --git a/app/src/main/java/com/miti99/loto/ui/theme/Type.kt b/app/src/main/java/com/miti99/loto/ui/theme/Type.kt new file mode 100644 index 0000000..0c84608 --- /dev/null +++ b/app/src/main/java/com/miti99/loto/ui/theme/Type.kt @@ -0,0 +1,41 @@ +package com.miti99.loto.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +/** + * Typography. Material 3 defaults with `displayLarge` overridden to italic + * black weight — used for the "Lô tô" wordmark gradient. + */ +val LotoTypography = Typography( + displayLarge = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Black, + fontStyle = FontStyle.Italic, + fontSize = 56.sp, + lineHeight = 64.sp, + letterSpacing = (-1).sp, + ), + displayMedium = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Black, + fontSize = 44.sp, + lineHeight = 52.sp, + ), + titleLarge = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Bold, + fontSize = 22.sp, + lineHeight = 28.sp, + ), + bodyLarge = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + ), +) diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..a4086fc --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2ec6c61 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..837ba00 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,75 @@ + + + Lô tô + + + Lô tô + TN1 (2014-2017) + Độc-Đỉnh-Điên + + + Tạo bảng mới + Xoá đánh dấu + Bạn có muốn tạo lại bảng không? + Bạn có muốn xoá tất cả đánh dấu không? + + Không + + + Nhấn + để bắt đầu chơi + "Tạo bảng mới" + 🎫 Chúc cả nhà một ván vui vẻ + + + Đóng thông báo + Kinh! + Hàng + đã đầy đủ! + Hãy hô to \"Kinh!\" 🎶 + Tuyệt vời! 🥳 + Đóng + Bảng lô tô + + + Ván mới + Xổ số + Bắt đầu + Dừng + Bạn có muốn tạo ván mới không? + Nhấn "Ván mới" để bắt đầu + Số vừa xổ + Đã xổ: + Còn lại: + Thứ tự đã xổ: + Tự động: %1$ds/số + Bảng theo dõi số đã xổ + + + Cài đặt + Giao diện + Tự động + Sáng + Tối + Chế độ + Người chơi + Quản trò + Cả hai + Tự động xổ số + Bật tự động + Tốc độ: %1$d giây + Giọng đọc + Quản trò: đọc số + Người chơi: đọc Chờ / Kinh + Đọc số khi Chờ + Chọn giọng + Màu ô trống + Tùy chỉnh + R + G + B + Khôi phục mặc định + + + Hội chợ TN1 + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..09929ac --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..3fa7b0e --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/app/src/test/java/com/miti99/loto/game/GameLogicTest.kt b/app/src/test/java/com/miti99/loto/game/GameLogicTest.kt new file mode 100644 index 0000000..d398fb5 --- /dev/null +++ b/app/src/test/java/com/miti99/loto/game/GameLogicTest.kt @@ -0,0 +1,166 @@ +package com.miti99.loto.game + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.RepeatedTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll +import com.miti99.loto.game.GameLogic.NUM_COLS +import com.miti99.loto.game.GameLogic.NUM_PER_ROW +import com.miti99.loto.game.GameLogic.NUM_ROWS +import com.miti99.loto.game.GameLogic.generateGrid +import com.miti99.loto.game.GameLogic.getWaitingNumber +import com.miti99.loto.game.GameLogic.isRowComplete + +/** + * Port of `tiennm99/loto/src/lib/game-logic.test.js`. Persistence tests + * (saveGrid/loadGrid) intentionally NOT ported — that role moved to + * DataStore in phase 05. + */ +class GameLogicTest { + + private val COL_RANGES = listOf( + 1..9, 10..19, 20..29, 30..39, 40..49, + 50..59, 60..69, 70..79, 80..90, + ) + + private fun rowCounts(grid: Array): IntArray = + IntArray(NUM_ROWS) { r -> grid[r].count { it > 0 } } + + private fun colCounts(grid: Array): IntArray = + IntArray(NUM_COLS) { c -> (0 until NUM_ROWS).count { r -> grid[r][c] > 0 } } + + @Test + fun `returns a 9x9 matrix`() { + val g = generateGrid() + assertEquals(NUM_ROWS, g.size) + for (row in g) assertEquals(NUM_COLS, row.size) + } + + @RepeatedTest(200) + fun `every row has exactly 5 non-zero numbers`() { + val counts = rowCounts(generateGrid()) + assertTrue(counts.all { it == NUM_PER_ROW }) { counts.joinToString() } + } + + @RepeatedTest(200) + fun `every column has exactly 5 non-zero numbers`() { + val counts = colCounts(generateGrid()) + assertTrue(counts.all { it == NUM_PER_ROW }) { counts.joinToString() } + } + + @RepeatedTest(50) + fun `never produces duplicates in a single card`() { + val flat = generateGrid().flatMap { it.toList() }.filter { it > 0 } + assertEquals(flat.size, flat.toSet().size) + } + + @RepeatedTest(50) + fun `each non-zero cell sits in its column's tens range`() { + val g = generateGrid() + for (r in 0 until NUM_ROWS) for (c in 0 until NUM_COLS) { + val n = g[r][c] + if (n == 0) continue + assertTrue(n in COL_RANGES[c]) { "row=$r col=$c num=$n" } + } + } + + @RepeatedTest(50) + fun `numbers within each column are sorted ascending top-to-bottom`() { + val g = generateGrid() + for (c in 0 until NUM_COLS) { + val colNums = (0 until NUM_ROWS).map { g[it][c] }.filter { it > 0 } + assertEquals(colNums.sorted(), colNums) + } + } + + @RepeatedTest(300) + fun `no row has 3 consecutive filled columns (soft constraint)`() { + val g = generateGrid() + for (r in 0 until NUM_ROWS) { + for (c in 0..NUM_COLS - 3) { + assertFalse(g[r][c] > 0 && g[r][c + 1] > 0 && g[r][c + 2] > 0) { + "row=$r cols=$c,${c + 1},${c + 2}" + } + } + } + } + + @Test + fun `col 0 only holds numbers from 1 to 9 (5 per card)`() { + val g = generateGrid() + val col0 = (0 until NUM_ROWS).map { g[it][0] }.filter { it > 0 } + assertEquals(NUM_PER_ROW, col0.size) + assertAll(col0.map { n -> Runnable { assertTrue(n in 1..9) } }) + } + + @Test + fun `col 8 only holds numbers from 80 to 90 (5 per card)`() { + val g = generateGrid() + val col8 = (0 until NUM_ROWS).map { g[it][8] }.filter { it > 0 } + assertEquals(NUM_PER_ROW, col8.size) + assertAll(col8.map { n -> Runnable { assertTrue(n in 80..90) } }) + } + + // ----- isRowComplete ----- + + @Test + fun `isRowComplete true when every number in row is crossed`() { + val grid = arrayOf(intArrayOf(0, 1, 0, 2, 0, 3, 0, 4, 5)) + val crossed = arrayOf(booleanArrayOf(false, true, false, true, false, true, false, true, true)) + assertTrue(isRowComplete(grid, crossed, 0)) + } + + @Test + fun `isRowComplete false when at least one number uncrossed`() { + val grid = arrayOf(intArrayOf(0, 1, 0, 2, 0, 3, 0, 4, 5)) + val crossed = arrayOf(booleanArrayOf(false, true, false, false, false, true, false, true, true)) + assertFalse(isRowComplete(grid, crossed, 0)) + } + + @Test + fun `isRowComplete false for all-zero row (no numbers, not a win)`() { + val grid = arrayOf(IntArray(9)) + val crossed = arrayOf(BooleanArray(9)) + assertFalse(isRowComplete(grid, crossed, 0)) + } + + @Test + fun `isRowComplete ignores zero cells when checking crossed state`() { + val grid = arrayOf(intArrayOf(0, 7, 0, 0, 0, 0, 0, 0, 0)) + val crossed = arrayOf(booleanArrayOf(false, true, false, false, false, false, false, false, false)) + assertTrue(isRowComplete(grid, crossed, 0)) + } + + // ----- getWaitingNumber ----- + + @Test + fun `getWaitingNumber returns single uncrossed number when exactly one remains`() { + val grid = arrayOf(intArrayOf(0, 1, 0, 2, 0, 3, 0, 4, 5)) + val crossed = arrayOf(booleanArrayOf(false, true, false, false, false, true, false, true, true)) + assertEquals(2, getWaitingNumber(grid, crossed, 0)) + } + + @Test + fun `getWaitingNumber null when more than one number remains`() { + val grid = arrayOf(intArrayOf(0, 1, 0, 2, 0, 3, 0, 4, 5)) + val crossed = arrayOf(booleanArrayOf(false, true, false, false, false, false, false, true, true)) + assertNull(getWaitingNumber(grid, crossed, 0)) + } + + @Test + fun `getWaitingNumber null when zero numbers remain (row complete)`() { + val grid = arrayOf(intArrayOf(0, 1, 0, 2, 0, 3, 0, 4, 5)) + val crossed = arrayOf(booleanArrayOf(false, true, false, true, false, true, false, true, true)) + assertNull(getWaitingNumber(grid, crossed, 0)) + } + + @Test + fun `getWaitingNumber null for empty row`() { + val grid = arrayOf(IntArray(9)) + val crossed = arrayOf(BooleanArray(9)) + assertNull(getWaitingNumber(grid, crossed, 0)) + } +} diff --git a/app/src/test/java/com/miti99/loto/game/VietnameseNumberTest.kt b/app/src/test/java/com/miti99/loto/game/VietnameseNumberTest.kt new file mode 100644 index 0000000..889af14 --- /dev/null +++ b/app/src/test/java/com/miti99/loto/game/VietnameseNumberTest.kt @@ -0,0 +1,34 @@ +package com.miti99.loto.game + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource + +/** + * Port of `tiennm99/loto/src/lib/vietnamese-number.test.js`. Out-of-range + * fallback to `n.toString()` matches the JS source. + */ +class VietnameseNumberTest { + + @ParameterizedTest(name = "{0} → {1}") + @CsvSource( + // units 0..9 + "0,'không'", "1,'một'", "2,'hai'", "3,'ba'", "4,'bốn'", + "5,'năm'", "6,'sáu'", "7,'bảy'", "8,'tám'", "9,'chín'", + // teens 10..19 with mười lăm exception + "10,'mười'", "11,'mười một'", "12,'mười hai'", "13,'mười ba'", "14,'mười bốn'", + "15,'mười lăm'", "16,'mười sáu'", "17,'mười bảy'", "18,'mười tám'", "19,'mười chín'", + // 20..90 with mốt and lăm exceptions + "20,'hai mươi'", "21,'hai mươi mốt'", "22,'hai mươi hai'", + "25,'hai mươi lăm'", "29,'hai mươi chín'", + "30,'ba mươi'", "31,'ba mươi mốt'", + "40,'bốn mươi'", "45,'bốn mươi lăm'", + "55,'năm mươi lăm'", "61,'sáu mươi mốt'", + "70,'bảy mươi'", "81,'tám mươi mốt'", "85,'tám mươi lăm'", "90,'chín mươi'", + // out-of-range fallback + "-1,'-1'", "91,'91'", "100,'100'", + ) + fun `numberToVietnamese matches table`(n: Int, expected: String) { + assertEquals(expected, VietnameseNumber.numberToVietnamese(n)) + } +} diff --git a/app/src/test/java/com/miti99/loto/settings/SettingsRepositoryTest.kt b/app/src/test/java/com/miti99/loto/settings/SettingsRepositoryTest.kt new file mode 100644 index 0000000..3109932 --- /dev/null +++ b/app/src/test/java/com/miti99/loto/settings/SettingsRepositoryTest.kt @@ -0,0 +1,133 @@ +package com.miti99.loto.settings + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import app.cash.turbine.test +import com.miti99.loto.settings.SettingsKeys as K +import kotlin.io.path.Path +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +/** + * Port of `tiennm99/loto/src/lib/settings-store.test.js`. + * Uses real DataStore on a temp file; faster than Robolectric. + */ +class SettingsRepositoryTest { + + @TempDir lateinit var tempDir: File + private lateinit var dataStore: DataStore + private lateinit var scope: TestScope + private lateinit var repo: SettingsRepository + + @BeforeEach + fun setUp() { + val testScope = TestScope(StandardTestDispatcher()) + scope = testScope + dataStore = PreferenceDataStoreFactory.create( + scope = CoroutineScope(SupervisorJob()), + produceFile = { File(tempDir, "settings.preferences_pb") }, + migrations = listOf(SettingsRepository.legacyMasterModeMigration), + ) + repo = SettingsRepository(dataStore, appScope = testScope) + } + + @Test + fun `loads defaults when empty`() = runTest { + assertEquals(SettingsState.DEFAULT, repo.flow.first()) + } + + @Test + fun `roundtrips each field`() = runTest { + repo.setEmptyCellColor("#abcdef") + repo.setTheme(SettingsState.Theme.DARK) + repo.setMode(SettingsState.Mode.MASTER) + repo.setAutoCallEnabled(true) + repo.setAutoCallSpeed(8) + repo.setVoiceEnabledMaster(false) + repo.setVoiceEnabledPlayer(true) + repo.setVoiceWaitingNumber(true) + repo.setVoice("nam-minh") + + val state = repo.flow.first() + assertEquals("#abcdef", state.emptyCellColor) + assertEquals(SettingsState.Theme.DARK, state.theme) + assertEquals(SettingsState.Mode.MASTER, state.mode) + assertEquals(true, state.autoCallEnabled) + assertEquals(8, state.autoCallSpeed) + assertEquals(false, state.voiceEnabledMaster) + assertEquals(true, state.voiceEnabledPlayer) + assertEquals(true, state.voiceWaitingNumber) + assertEquals("nam-minh", state.voice) + } + + @Test + fun `rejects invalid color falls back to default on read`() = runTest { + dataStore.edit { it[K.EMPTY_CELL_COLOR] = "#zzzzzz" } + assertEquals(SettingsState.DEFAULT.emptyCellColor, repo.flow.first().emptyCellColor) + } + + @Test + fun `clamps out-of-range speed when set`() = runTest { + repo.setAutoCallSpeed(15) + assertEquals(10, repo.flow.first().autoCallSpeed) + repo.setAutoCallSpeed(-3) + assertEquals(1, repo.flow.first().autoCallSpeed) + } + + @Test + fun `out-of-range stored speed falls back to default`() = runTest { + dataStore.edit { it[K.AUTO_CALL_SPEED] = 99 } + assertEquals(SettingsState.DEFAULT.autoCallSpeed, repo.flow.first().autoCallSpeed) + } + + @Test + fun `rejects unknown theme on read`() = runTest { + dataStore.edit { it[K.THEME] = "neon" } + assertEquals(SettingsState.Theme.AUTO, repo.flow.first().theme) + } + + @Test + fun `rejects unknown voice on write`() = runTest { + repo.setVoice("ghost-voice") + assertEquals(SettingsState.DEFAULT.voice, repo.flow.first().voice) + } + + @Test + fun `rejects unknown voice on read`() = runTest { + dataStore.edit { it[K.VOICE] = "ghost-voice" } + assertEquals(SettingsState.DEFAULT.voice, repo.flow.first().voice) + } + + @Test + fun `migrates legacy masterMode true to mode both`() = runTest { + dataStore.edit { it[K.LEGACY_MASTER_MODE] = true } + // Trigger migration by reading + assertEquals(SettingsState.Mode.BOTH, repo.flow.first().mode) + } + + @Test + fun `legacy masterMode false uses default mode player`() = runTest { + dataStore.edit { it[K.LEGACY_MASTER_MODE] = false } + assertEquals(SettingsState.Mode.PLAYER, repo.flow.first().mode) + } + + @Test + fun `reset restores all defaults`() = runTest { + repo.setTheme(SettingsState.Theme.DARK) + repo.setVoice("nam-minh") + repo.reset() + assertEquals(SettingsState.DEFAULT, repo.flow.first()) + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..80e45e8 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,7 @@ +// Top-level build file. Plugins applied per-module. +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.kotlin.serialization) apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..49d40dc --- /dev/null +++ b/gradle.properties @@ -0,0 +1,9 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.configuration-cache=true + +kotlin.code.style=official + +android.useAndroidX=true +android.nonTransitiveRClass=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..2894f88 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,75 @@ +[versions] +agp = "8.7.3" +kotlin = "2.1.0" +compose-bom = "2025.01.00" +activity-compose = "1.10.0" +lifecycle = "2.8.7" +media3 = "1.5.1" +datastore = "1.1.1" +serialization = "1.7.3" +coroutines = "1.9.0" +desugar-jdk-libs = "2.1.4" +junit-jupiter = "5.11.4" +turbine = "1.2.0" +robolectric = "4.14.1" +androidx-test-ext = "1.2.1" +androidx-test-runner = "1.6.2" +android-junit5 = "1.11.2.0" + +[libraries] +# Compose BOM and bundled deps +compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } +compose-ui = { group = "androidx.compose.ui", name = "ui" } +compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +compose-material3 = { group = "androidx.compose.material3", name = "material3" } +compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } + +# Activity + Lifecycle +activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } +lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } +lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } + +# Audio (Media3 ExoPlayer) +media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } +media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "media3" } + +# Persistence +datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } + +# Serialization + Coroutines +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } + +# Java 17 desugaring (java.time on minSdk 24) +desugar-jdk-libs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "desugar-jdk-libs" } + +# Tests +junit-jupiter = { group = "org.junit.jupiter", name = "junit-jupiter", version.ref = "junit-jupiter" } +junit-jupiter-params = { group = "org.junit.jupiter", name = "junit-jupiter-params", version.ref = "junit-jupiter" } +turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext" } +androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidx-test-runner" } + +[bundles] +compose-ui = [ + "compose-ui", + "compose-ui-graphics", + "compose-ui-tooling-preview", + "compose-material3", + "activity-compose", + "lifecycle-runtime-compose", + "lifecycle-viewmodel-compose", +] + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +android-junit5 = { id = "de.mannodermaus.android-junit5", version.ref = "android-junit5" } diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..c3c5bc1 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,24 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "loto-android" +include(":app")