feat(android)!: replace the Capacitor wrapper with a native Kotlin/Compose app

Fresh Kotlin 2.2 + Jetpack Compose app at feature parity with web/
(game logic, settings contract, voice semantics ported one-to-one with
twinned tests; 104 unit + 9 instrumentation tests). Fully offline:
VIBRATE is the only permission; Media3's ACCESS_NETWORK_STATE is
stripped. Voice clips mount from web/static/audio at build time.

Same applicationId, signing env contract, and Play alpha track;
versionCode 7 / 0.2.0. CI Android job is Gradle-only (lint, test,
debug + R8 release APK) and the release workflow fails fast on an
unbumped versionCode.

BREAKING CHANGE: WebView localStorage (settings, in-progress round)
is not migrated; wrapper users start clean.
This commit is contained in:
tiennm99 committed 2026-08-31 13:28:48 +07:00
1 parent 8f9b03ca05
commit e314ae01c8
126 files changed
+6390 -2275

No files matched your search

@@ -0,0 +1,80 @@
package com.miti99.loto
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Master panel critical interactions: draw updates hero + history, reset
* asks for confirmation mid-round. (Auto-call relabeling needs the
* autoCallEnabled setting — toggled through the repository.)
*/
@RunWith(AndroidJUnit4::class)
class MasterPanelScreenTest {
@get:Rule
val composeRule = createAndroidComposeRule<MainActivity>()
private val app: LotoApplication
get() = InstrumentationRegistry.getInstrumentation()
.targetContext.applicationContext as LotoApplication
@Before
fun switchToMasterMode() {
// Reset first — instrumentation tests share the app's DataStore.
runBlocking {
app.settingsRepository.reset()
app.settingsRepository.setMode(com.miti99.loto.settings.AppMode.MASTER)
}
composeRule.waitForIdle()
}
/** Starts a round, clicking through the confirm dialog a persisted round raises. */
private fun startNewGame() {
composeRule.onNodeWithText("Ván mới").performClick()
composeRule.waitForIdle()
val confirm = composeRule.onAllNodesWithText("Đồng ý").fetchSemanticsNodes()
if (confirm.isNotEmpty()) {
composeRule.onNodeWithText("Đồng ý").performClick()
composeRule.waitForIdle()
}
}
@Test
fun drawUpdatesHeroAndHistory() {
startNewGame()
composeRule.onNodeWithText("Xổ số").performClick()
composeRule.waitForIdle()
composeRule.onNodeWithText("SỐ VỪA XỔ").assertExists()
composeRule.onNodeWithText("Thứ tự đã xổ:").assertExists()
}
@Test
fun midRoundResetAsksForConfirmation() {
startNewGame()
composeRule.onNodeWithText("Xổ số").performClick()
composeRule.waitForIdle()
composeRule.onNodeWithText("Ván mới").performClick()
composeRule.onNodeWithText("Bạn có muốn tạo ván mới không?").assertExists()
}
@Test
fun autoCallSettingRelabelsTheDrawButton() {
runBlocking { app.settingsRepository.setAutoCallEnabled(true) }
startNewGame()
composeRule.onNodeWithText("Bắt đầu").performClick()
composeRule.waitForIdle()
composeRule.onNodeWithText("Dừng").assertExists()
composeRule.onNodeWithText("Dừng").performClick()
composeRule.waitForIdle()
composeRule.onNodeWithText("Bắt đầu").assertExists()
}
}
@@ -0,0 +1,94 @@
package com.miti99.loto
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Critical player interactions: generate a card, tap-cross a cell, drive a
* row to chờ and then to Kinh. Cells are found via their state descriptions
* ("Số N[, đã đánh dấu][, đang chờ]"); composition order is row-major, so
* the first five number cells belong to row 0.
*/
@RunWith(AndroidJUnit4::class)
class PlayerBoardScreenTest {
@get:Rule
val composeRule = createAndroidComposeRule<MainActivity>()
@Before
fun resetSettings() {
// Instrumentation tests share the app's DataStore across classes;
// make sure the player board is visible regardless of run order.
val app = InstrumentationRegistry.getInstrumentation()
.targetContext.applicationContext as LotoApplication
runBlocking { app.settingsRepository.reset() }
composeRule.waitForIdle()
}
private fun numberCells() = composeRule.onAllNodes(
SemanticsMatcher("has number-cell state") {
it.config.getOrNull(SemanticsProperties.StateDescription)?.startsWith("Số ") == true
},
)
private fun generateCard() {
composeRule.onNodeWithText("Tạo bảng mới").performClick()
composeRule.waitForIdle()
// A persisted round from an earlier test raises the confirm dialog.
val confirm = composeRule.onAllNodesWithText("Đồng ý").fetchSemanticsNodes()
if (confirm.isNotEmpty()) {
composeRule.onNodeWithText("Đồng ý").performClick()
composeRule.waitForIdle()
}
}
@Test
fun generateShowsA45NumberCard() {
generateCard()
numberCells().assertCountEquals(45)
}
@Test
fun tappingACellMarksIt() {
generateCard()
numberCells()[0].performClick()
composeRule.waitForIdle()
composeRule.onAllNodes(
SemanticsMatcher("crossed cell") {
it.config.getOrNull(SemanticsProperties.StateDescription)
?.contains("đã đánh dấu") == true
},
).assertCountEquals(1)
}
@Test
fun completingARowShowsChoThenKinh() {
generateCard()
// First five number cells are row 0's numbers (row-major order).
repeat(4) { i -> numberCells()[i].performClick() }
composeRule.waitForIdle()
composeRule.onNodeWithText("Chờ ", substring = true).assertExists()
numberCells()[4].performClick()
composeRule.waitForIdle()
composeRule.onNodeWithText("Kinh!").assertExists()
}
}
private fun androidx.compose.ui.test.SemanticsNodeInteractionCollection.assertCountEquals(
expected: Int,
) = fetchSemanticsNodes().let { nodes ->
check(nodes.size == expected) { "expected $expected nodes, found ${nodes.size}" }
}
@@ -0,0 +1,64 @@
package com.miti99.loto
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.espresso.Espresso
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/** Cross-cutting settings: display mode switches the root layout live. */
@RunWith(AndroidJUnit4::class)
class SettingsSheetTest {
@get:Rule
val composeRule = createAndroidComposeRule<MainActivity>()
private val app: LotoApplication
get() = InstrumentationRegistry.getInstrumentation()
.targetContext.applicationContext as LotoApplication
@Before
fun resetSettings() {
runBlocking { app.settingsRepository.reset() }
composeRule.waitForIdle()
}
private fun openSheet() {
composeRule.onNodeWithContentDescription("Cài đặt").performClick()
composeRule.waitForIdle()
}
@Test
fun switchingToMasterModeSwapsThePanels() {
composeRule.onNodeWithText("Tạo bảng mới").assertExists()
openSheet()
composeRule.onNodeWithText("Quản trò").performClick()
// The write goes through DataStore; wait for the mode to land.
composeRule.waitUntil(timeoutMillis = 5_000) {
app.settingsState.value.mode == com.miti99.loto.settings.AppMode.MASTER
}
composeRule.waitForIdle()
// Back closes the sheet (also a device-QA item); the mode change
// already applied live.
Espresso.pressBack()
composeRule.waitForIdle()
composeRule.onNodeWithText("Ván mới").assertExists()
}
@Test
fun sheetExposesEverydaySections() {
openSheet()
composeRule.onNodeWithText("Giao diện").assertExists()
composeRule.onNodeWithText("Cỡ chữ bảng").assertExists()
composeRule.onNodeWithText("Chế độ hiển thị").assertExists()
composeRule.onNodeWithText("Âm thanh").assertExists()
composeRule.onNodeWithText("Màu ô trống").assertExists()
}
}
@@ -0,0 +1,20 @@
package com.miti99.loto
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class SmokeTest {
@get:Rule
val composeRule = createAndroidComposeRule<MainActivity>()
@Test
fun launchShowsAppName() {
composeRule.onNodeWithText("Lô tô").assertExists()
}
}
+56
View File
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- No INTERNET permission: the app is fully offline, all assets are
bundled in the APK. -->
<!-- Haptic feedback on cell taps. Normal permission: no runtime prompt,
no data-safety impact. -->
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Media3 ExoPlayer merges this in for network-requirement watching; the
app plays bundled assets only, so strip it to keep the hard offline
guarantee (VIBRATE must stay the only permission). -->
<uses-permission
android:name="android.permission.ACCESS_NETWORK_STATE"
tools:node="remove" />
<!-- Mark hardware as optional so emulators (BlueStacks, NoxPlayer, AVDs)
don't get filtered out on Play. BlueStacks reports `faketouch`
instead of `touchscreen` and lacks real telephony/GPS hardware. -->
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-feature
android:name="android.hardware.faketouch"
android:required="false" />
<uses-feature
android:name="android.hardware.screen.portrait"
android:required="false" />
<uses-feature
android:name="android.hardware.screen.landscape"
android:required="false" />
<application
android:name=".LotoApplication"
android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="false"
android:theme="@style/Theme.Loto">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:theme="@style/Theme.Loto.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,84 @@
package com.miti99.loto
import android.app.Application
import android.content.Context
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.preferencesDataStore
import com.miti99.loto.audio.ExoVoicePlayer
import com.miti99.loto.audio.Voice
import com.miti99.loto.audio.VoiceCatalog
import com.miti99.loto.audio.VoicePlayerApi
import com.miti99.loto.settings.Settings
import com.miti99.loto.settings.SettingsRepository
import com.miti99.loto.state.GameStateRepository
import com.miti99.loto.state.MasterStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
// Settings and round state live in separate stores (the web keeps them under
// separate localStorage keys too) so frequent round writes never contend
// with settings writes. A corrupt file falls back to empty preferences —
// losing one round beats crash-looping on every launch (the web's
// try/catch-everything localStorage posture).
private val Context.settingsDataStore by preferencesDataStore(
name = "loto_settings",
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
)
private val Context.gameStateDataStore by preferencesDataStore(
name = "loto_game_state",
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
)
/**
* Process-wide singletons live here (manual DI — the app is small enough
* that a DI framework would cost more than it saves).
*/
class LotoApplication : Application() {
val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
/** Bundled announcer voices, parsed once from the asset manifest. */
val voices: List<Voice> by lazy { VoiceCatalog.load(this) }
val settingsRepository: SettingsRepository by lazy {
SettingsRepository(
dataStore = settingsDataStore,
validVoiceIds = voices.map { it.id }.toSet(),
defaultVoiceId = VoiceCatalog.defaultVoiceId(voices),
)
}
/** App-scoped settings snapshot every ViewModel reads from. */
val settingsState: StateFlow<Settings> by lazy {
settingsRepository.settingsFlow
.stateIn(appScope, SharingStarted.Eagerly, settingsRepository.defaults)
}
val gameStateRepository: GameStateRepository by lazy {
GameStateRepository(gameStateDataStore)
}
val masterStore: MasterStore by lazy {
MasterStore(gameStateRepository, appScope)
}
/** Built on the main thread (ExoPlayer requirement). */
val voicePlayer: VoicePlayerApi by lazy {
ExoVoicePlayer(this).also { it.voiceId = VoiceCatalog.defaultVoiceId(voices) }
}
override fun onCreate() {
super.onCreate()
appScope.launch {
masterStore.restore()
// Keep the announcer on the configured voice.
settingsState.collect { voicePlayer.voiceId = it.voice }
}
}
}
@@ -0,0 +1,105 @@
package com.miti99.loto
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.ui.res.stringResource
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.miti99.loto.state.LotoViewModelFactory
import com.miti99.loto.state.MasterPanelViewModel
import com.miti99.loto.state.PlayerBoardViewModel
import com.miti99.loto.state.SettingsViewModel
import com.miti99.loto.ui.LotoAppRoot
import com.miti99.loto.ui.settings.SettingsSheet
import com.miti99.loto.ui.theme.LotoTheme
class MainActivity : ComponentActivity() {
private val factory by lazy { LotoViewModelFactory(application as LotoApplication) }
private val playerViewModel: PlayerBoardViewModel by viewModels { factory }
private val masterViewModel: MasterPanelViewModel by viewModels { factory }
private val settingsViewModel: SettingsViewModel by viewModels { factory }
override fun onStart() {
super.onStart()
masterViewModel.setForeground(true)
}
override fun onStop() {
super.onStop()
// Pause auto-call while backgrounded — numbers nobody hears must not
// drain the deck (the web's hidden tab was throttled for free).
masterViewModel.setForeground(false)
}
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val app = application as LotoApplication
setContent {
val settings by app.settingsState.collectAsState()
var showSettings by remember { mutableStateOf(false) }
var showExitConfirm by remember { mutableStateOf(false) }
LotoTheme(theme = settings.theme) {
// Compose dialogs and the settings sheet consume back in
// their own windows; this fires only at the root.
BackHandler { showExitConfirm = true }
LotoAppRoot(
settings = settings,
playerViewModel = playerViewModel,
masterViewModel = masterViewModel,
settingsButton = {
IconButton(onClick = { showSettings = true }) {
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = stringResource(R.string.settings_open),
tint = LotoTheme.palette.subtitle,
)
}
},
)
if (showSettings) {
SettingsSheet(
viewModel = settingsViewModel,
onDismiss = { showSettings = false },
)
}
if (showExitConfirm) {
// Same copy as the wrapper's exit dialog; state is already
// persisted continuously, so "Thoát" can simply finish.
AlertDialog(
onDismissRequest = { showExitConfirm = false },
title = { Text(stringResource(R.string.exit_title)) },
text = { Text(stringResource(R.string.exit_message)) },
confirmButton = {
TextButton(onClick = { finish() }) {
Text(stringResource(R.string.exit_confirm))
}
},
dismissButton = {
TextButton(onClick = { showExitConfirm = false }) {
Text(stringResource(R.string.exit_cancel))
}
},
)
}
}
}
}
}
@@ -0,0 +1,83 @@
package com.miti99.loto.audio
import android.content.Context
import org.json.JSONObject
/**
* A bundled announcer voice, mirroring the entries of
* `assets/audio/manifest.json` (the same file the web app imports).
*/
data class Voice(
val id: String,
val edgeName: String,
val label: String,
val gender: String,
)
/**
* Catalog of the bundled voices. Clips live under
* `assets/audio/{voiceId}/{1..90,cho,kinh}.mp3`; the manifest is produced by
* `scripts/generate-audio.py` in the web app and shipped verbatim.
*/
object VoiceCatalog {
const val MANIFEST_ASSET_PATH = "audio/manifest.json"
/** Clip basename for the "Chờ" announcement. */
const val CLIP_CHO = "cho"
/** Clip basename for the "Kinh" (row win) announcement. */
const val CLIP_KINH = "kinh"
/**
* Known-good voices matching the shipped assets, used when the manifest
* is missing or malformed (mirror of the web app's defensive posture —
* the first entry is the default voice).
*/
val FALLBACK_VOICES = listOf(
Voice("hoai-my", "vi-VN-HoaiMyNeural", "Hoai My (nữ)", "female"),
Voice("nam-minh", "vi-VN-NamMinhNeural", "Nam Minh (nam)", "male"),
)
/** Parse manifest JSON; any shape problem falls back to [FALLBACK_VOICES]. */
fun parse(json: String): List<Voice> {
return try {
val voicesJson = JSONObject(json).getJSONArray("voices")
val voices = (0 until voicesJson.length()).map { i ->
val v = voicesJson.getJSONObject(i)
Voice(
id = v.getString("id"),
edgeName = v.getString("edgeName"),
label = v.getString("label"),
gender = v.getString("gender"),
)
}
voices.ifEmpty { FALLBACK_VOICES }
} catch (_: Exception) {
FALLBACK_VOICES
}
}
/** Read and parse the bundled manifest; falls back on any IO/parse error. */
fun load(context: Context): List<Voice> {
return try {
val json = context.assets.open(MANIFEST_ASSET_PATH)
.bufferedReader()
.use { it.readText() }
parse(json)
} catch (_: Exception) {
FALLBACK_VOICES
}
}
/** First entry is the default — the generator writes them in edge-tts order. */
fun defaultVoiceId(voices: List<Voice>): String =
voices.firstOrNull()?.id ?: FALLBACK_VOICES.first().id
/**
* Asset path of one clip, relative to the assets root.
* [clipName] ∈ "1".."90" | [CLIP_CHO] | [CLIP_KINH].
*/
fun clipPath(voiceId: String, clipName: String): String =
"audio/$voiceId/$clipName.mp3"
}
@@ -0,0 +1,101 @@
package com.miti99.loto.audio
import android.content.Context
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.ExoPlayer
/**
* Playback abstraction the ViewModels depend on; unit tests substitute a
* recording fake, the production implementation is [ExoVoicePlayer].
*
* Cancellation contract (ported from `web/src/lib/voice.js`): every [speak]
* call replaces any in-flight utterance — clips never overlap or queue
* across calls. Switching the voice takes effect on the next utterance.
*/
interface VoicePlayerApi {
/** The active voice id (folder slug under `assets/audio/`). */
var voiceId: String
/** Play [clipNames] as one gapless sequence, canceling any in-flight one. */
fun speak(clipNames: List<String>)
/** Stop the in-flight utterance, if any. */
fun cancel()
/** Release playback resources; the instance is unusable afterwards. */
fun release()
}
/** Announce a drawn number. */
fun VoicePlayerApi.playNumber(n: Int) = speak(listOf(n.toString()))
/**
* Announce "Chờ", optionally chained with the awaited number.
* Gating ported from the web's `playWaiting`: the trailing number is
* suppressed in both mode — the master is already calling numbers aloud, so
* "Chờ 42" right after a "33" call confuses listeners who can't tell which
* number is the active draw.
*/
fun VoicePlayerApi.playWaiting(n: Int, voiceWaitingNumber: Boolean, modeIsBoth: Boolean) =
speak(waitingClips(n, voiceWaitingNumber, modeIsBoth))
/** Announce "Kinh" (row win). */
fun VoicePlayerApi.playBingo() = speak(listOf(VoiceCatalog.CLIP_KINH))
/** Pure clip-sequence rule for the chờ announcement (see [playWaiting]). */
fun waitingClips(n: Int, voiceWaitingNumber: Boolean, modeIsBoth: Boolean): List<String> =
if (voiceWaitingNumber && !modeIsBoth) {
listOf(VoiceCatalog.CLIP_CHO, n.toString())
} else {
listOf(VoiceCatalog.CLIP_CHO)
}
/**
* Media3 ExoPlayer implementation over the bundled `asset:///audio/...`
* clips. Main-thread only (ExoPlayer requirement) — constructed and used
* from the UI thread.
*
* Audio-focus decision: the clips are ~1s speech cues layered over whatever
* the table is already listening to, so the player requests NO audio focus
* (music keeps playing, calls behave normally) but still pauses when audio
* would become noisy (headphones unplugged).
*/
class ExoVoicePlayer(context: Context) : VoicePlayerApi {
override var voiceId: String = VoiceCatalog.defaultVoiceId(VoiceCatalog.FALLBACK_VOICES)
private val player: ExoPlayer = ExoPlayer.Builder(context)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_SPEECH)
.build(),
/* handleAudioFocus = */ false,
)
.setHandleAudioBecomingNoisy(true)
.build()
override fun speak(clipNames: List<String>) {
if (clipNames.isEmpty()) return
// Replacing the playlist stops the in-flight utterance — the native
// equivalent of voice.js's token cancellation.
player.setMediaItems(
clipNames.map { clip ->
MediaItem.fromUri("asset:///${VoiceCatalog.clipPath(voiceId, clip)}")
},
)
player.prepare()
player.play()
}
override fun cancel() {
player.stop()
player.clearMediaItems()
}
override fun release() {
player.release()
}
}
@@ -0,0 +1,156 @@
package com.miti99.loto.game
import kotlin.random.Random
/**
* Lô tô card generation, ported one-to-one from the web app's
* `web/src/lib/game-logic.js` (`generateGrid` and its helpers). The web
* implementation is the behavioral spec; any change there must be mirrored
* here.
*/
object CardGenerator {
const val NUM_ROWS = 9
const val NUM_COLS = 9
const val NUM_PER_ROW = 5
/** Number ranges for each column (0-8) in the lô tô grid. */
private val NUM_IN_COL: List<List<Int>> = listOf(
(1..9).toList(),
(10..19).toList(),
(20..29).toList(),
(30..39).toList(),
(40..49).toList(),
(50..59).toList(),
(60..69).toList(),
(70..79).toList(),
(80..90).toList(),
)
/**
* Generate a 9x9 lô tô grid with exactly [NUM_PER_ROW] filled cells per
* row AND per column. Cell values: 0 = empty, >0 = number.
*/
fun generateGrid(random: Random = Random.Default): List<List<Int>> {
val cell = Array(NUM_ROWS) { IntArray(NUM_COLS) }
val colsPerRow = pickFilledCols(random)
for (row in 0 until NUM_ROWS) {
for (col in colsPerRow[row]) cell[row][col] = -1
}
for (col in 0 until NUM_COLS) {
val picked = ArrayDeque(randomNumbersInCol(NUM_PER_ROW, col, random))
for (row in 0 until NUM_ROWS) {
if (cell[row][col] == -1) cell[row][col] = picked.removeFirstOrNull() ?: 0
}
}
return cell.map { it.toList() }
}
/**
* Pick [num] random numbers from column [col]'s range, returned ascending
* so they sit top-to-bottom in the column (lô tô hội chợ convention).
*/
private fun randomNumbersInCol(num: Int, col: Int, random: Random): List<Int> {
val arr = NUM_IN_COL[col].toMutableList()
// Fisher-Yates, matching the web implementation exactly.
for (i in arr.indices.reversed()) {
if (i == 0) break
val j = random.nextInt(i + 1)
val tmp = arr[i]
arr[i] = arr[j]
arr[j] = tmp
}
return arr.take(num).sorted()
}
/** Sorted strictly-ascending column indices contain 3 consecutive integers? */
internal fun hasThreeInARow(cols: List<Int>): Boolean {
for (i in 0..cols.size - 3) {
if (cols[i + 1] == cols[i] + 1 && cols[i + 2] == cols[i] + 2) return true
}
return false
}
/** Enumerate every k-sized combination of [arr] (preserves input order). */
private fun combinations(arr: List<Int>, k: Int): List<List<Int>> {
if (k == 0) return listOf(emptyList())
if (arr.size < k) return emptyList()
val out = mutableListOf<List<Int>>()
for (i in 0..arr.size - k) {
val head = arr[i]
for (tail in combinations(arr.subList(i + 1, arr.size), k - 1)) {
out.add(listOf(head) + tail)
}
}
return out
}
/**
* One attempt at picking the row-by-row column selection. Per-row picker
* prefers triple-free completions; if any row's forced set is already a
* triple (or no completion is triple-free), that row falls back to an
* unconstrained pick so the hard column-quota invariant never breaks.
*/
private fun pickFilledColsOnce(random: Random): List<List<Int>> {
val quota = IntArray(NUM_COLS) { NUM_PER_ROW }
val result = mutableListOf<List<Int>>()
for (row in 0 until NUM_ROWS) {
val rowsLeft = NUM_ROWS - row
val forced = mutableListOf<Int>()
val candidates = mutableListOf<Int>()
for (col in 0 until NUM_COLS) {
if (quota[col] == rowsLeft) forced.add(col)
else if (quota[col] > 0) candidates.add(col)
}
val need = NUM_PER_ROW - forced.size
val validCompletions = mutableListOf<List<Int>>()
if (!hasThreeInARow(forced)) {
for (combo in combinations(candidates, need)) {
val merged = (forced + combo).sorted()
if (!hasThreeInARow(merged)) validCompletions.add(merged)
}
}
val selected: List<Int>
if (validCompletions.isNotEmpty()) {
selected = validCompletions[random.nextInt(validCompletions.size)]
} else {
for (i in candidates.indices.reversed()) {
if (i == 0) break
val j = random.nextInt(i + 1)
val tmp = candidates[i]
candidates[i] = candidates[j]
candidates[j] = tmp
}
selected = (forced + candidates.take(need)).sorted()
}
for (col in selected) quota[col]--
result.add(selected)
}
return result
}
/**
* Choose which columns are filled in each row so that every row has
* exactly [NUM_PER_ROW] filled cells AND every column ends up with exactly
* [NUM_PER_ROW] filled cells. Soft constraint: no row has 3 consecutive
* filled columns.
*
* Strategy: per-row picker greedily prefers triple-free completions.
* Because early-row choices can still corner late rows into a forced
* triple, the whole pass is wrapped in rejection sampling. If every
* attempt fails (extremely rare), the last attempt is returned — column
* quotas hold either way.
*/
private fun pickFilledCols(random: Random): List<List<Int>> {
val maxAttempts = 200
var last = pickFilledColsOnce(random)
repeat(maxAttempts) {
if (last.all { !hasThreeInARow(it) }) return last
last = pickFilledColsOnce(random)
}
return last
}
}
@@ -0,0 +1,60 @@
package com.miti99.loto.game
import kotlin.random.Random
/**
* The master's draw deck, ported from `web/src/lib/master-store.svelte.js`.
* Forward-only: numbers move from [remaining] to [called] and never back;
* a new round replaces both lists wholesale.
*
* Pure state holder — persistence and reactivity live in the layers above.
*/
class DrawDeck(private val random: Random = Random.Default) {
var called: List<Int> = emptyList()
private set
var remaining: List<Int> = emptyList()
private set
/** Start a fresh round: empty called, full shuffled remaining. */
fun startNewGame() {
called = emptyList()
remaining = shuffled1to90()
}
/**
* Draw the next number from [remaining] into [called].
* @return the drawn number, or null if exhausted.
*/
fun drawNext(): Int? {
if (remaining.isEmpty()) return null
val next = remaining.first()
called = called + next
remaining = remaining.drop(1)
return next
}
/**
* Restore a previously persisted round. Callers own payload validation
* (range/shape checks live at the persistence layer, mirroring the web's
* `loadMaster`).
*/
fun restore(called: List<Int>, remaining: List<Int>) {
this.called = called
this.remaining = remaining
}
private fun shuffled1to90(): List<Int> {
val all = (1..90).toMutableList()
// Fisher-Yates, matching the web implementation.
for (i in all.indices.reversed()) {
if (i == 0) break
val j = random.nextInt(i + 1)
val tmp = all[i]
all[i] = all[j]
all[j] = tmp
}
return all
}
}
@@ -0,0 +1,60 @@
package com.miti99.loto.game
import com.miti99.loto.settings.AppMode
/**
* Pure helper for the master→player auto-cross path, ported one-to-one from
* `web/src/lib/player-auto-cross.js`. Reads the master's full call history
* (not a single-slot bus) so player regen, "Xoá đánh dấu", and restore can
* replay missed draws by passing `lastHandledIndex = 0`.
*
* The cursor advances strictly even when no cell flips (mode mismatch,
* manual untick, off-board) so the same draw never re-fires.
*/
object PlayerAutoCross {
data class Result(
val crossed: List<List<Boolean>>,
val lastHandledIndex: Int,
val changed: Boolean,
)
fun applyMasterCalls(
grid: List<List<Int>>?,
crossed: List<List<Boolean>>,
called: List<Int>,
lastHandledIndex: Int,
manualUnticks: Set<Int>,
mode: AppMode,
): Result {
// Hardening beyond the web: a shrunken history means the round was
// reset, so a stale cursor restarts from 0 instead of silently
// disabling auto-cross for the rest of the new round.
val cursor = if (lastHandledIndex > called.size) 0 else lastHandledIndex
if (cursor >= called.size) {
return Result(crossed, cursor, changed = false)
}
// In non-both modes the player isn't auto-following the master, but
// the cursor still advances — otherwise toggling player→both would
// dump the entire back-history at once. Only future draws auto-cross.
if (mode != AppMode.BOTH || grid == null || crossed.isEmpty()) {
return Result(crossed, called.size, changed = false)
}
var next = crossed
var changed = false
for (i in cursor until called.size) {
val num = called[i]
if (manualUnticks.contains(num)) continue
val target = PlayerCard.findUncrossedCell(grid, next, num) ?: continue
next = next.mapIndexed { ri, row ->
if (ri == target.row) {
row.mapIndexed { ci, v -> if (ci == target.col) true else v }
} else {
row
}
}
changed = true
}
return Result(next, called.size, changed)
}
}
@@ -0,0 +1,62 @@
package com.miti99.loto.game
/**
* Row-state helpers over a player card, ported one-to-one from
* `web/src/lib/game-logic.js`. A card is a 9x9 grid (0 = empty cell,
* >0 = number) plus a parallel crossed matrix.
*
* Row semantics: chờ (waiting) = exactly one uncrossed number remains in a
* row; kinh (win) = every number in a row is crossed. Both are row-based,
* never full-card.
*/
object PlayerCard {
/** Coordinates of a cell on the 9x9 grid. */
data class Cell(val row: Int, val col: Int)
/**
* Locate the first non-crossed cell holding [num] on the grid, scanning
* rows top-down and columns left-right. Returns null when the number is
* absent or already crossed everywhere. Used by the master→player
* auto-tick path.
*/
fun findUncrossedCell(grid: List<List<Int>>, crossed: List<List<Boolean>>, num: Int): Cell? {
if (num <= 0) return null
for (r in grid.indices) {
val row = grid[r]
for (c in row.indices) {
if (row[c] == num && crossed.getOrNull(r)?.getOrNull(c) != true) {
return Cell(r, c)
}
}
}
return null
}
/** Check if a row has all its numbers crossed (and has at least one number). */
fun isRowComplete(grid: List<List<Int>>, crossed: List<List<Boolean>>, row: Int): Boolean {
var hasNumber = false
for (col in 0 until CardGenerator.NUM_COLS) {
if (grid[row][col] > 0) {
hasNumber = true
if (crossed.getOrNull(row)?.getOrNull(col) != true) return false
}
}
return hasNumber
}
/**
* Find the single remaining uncrossed number in a row, or null when the
* remaining count differs from 1.
*/
fun getWaitingNumber(grid: List<List<Int>>, crossed: List<List<Boolean>>, row: Int): Int? {
var remaining: Int? = null
for (col in 0 until CardGenerator.NUM_COLS) {
if (grid[row][col] > 0 && crossed.getOrNull(row)?.getOrNull(col) != true) {
if (remaining != null) return null
remaining = grid[row][col]
}
}
return remaining
}
}
@@ -0,0 +1,70 @@
package com.miti99.loto.settings
/**
* Global UI settings, mirroring the web app's `DEFAULT_SETTINGS` contract in
* `web/src/lib/settings-store.svelte.js` — same fields, defaults, and
* validation rules. (The web's legacy `masterMode` migration is web-only;
* the native store starts clean.)
*/
data class Settings(
/** Hex6 background of empty board cells. Default: Excel "Standard Color: Purple". */
val emptyCellColor: String = DEFAULT_EMPTY_CELL_COLOR,
/** AUTO follows the OS dark-mode preference; LIGHT/DARK override it. */
val theme: ThemeSetting = ThemeSetting.AUTO,
/** Which panels are visible: player only, master only, or both inline. */
val mode: AppMode = AppMode.PLAYER,
/** When true, the master "Xổ số" button becomes "Bắt đầu/Dừng" + auto interval. */
val autoCallEnabled: Boolean = false,
/** Auto-call interval, seconds per number. Integer 1..10. */
val autoCallSpeed: Int = DEFAULT_AUTO_CALL_SPEED,
/** Speak the called number aloud when master draws. */
val voiceEnabledMaster: Boolean = true,
/** Speak "Chờ" / "Kinh" on player events. */
val voiceEnabledPlayer: Boolean = false,
/** When voiceEnabledPlayer is on, also speak the awaited number after "Chờ". */
val voiceWaitingNumber: Boolean = false,
/** Active voice id; matches an entry in the audio manifest. */
val voice: String,
/** Multiplier on board number size; one of [BOARD_TEXT_SCALES]. */
val boardTextScale: Float = DEFAULT_BOARD_TEXT_SCALE,
) {
companion object {
const val DEFAULT_EMPTY_CELL_COLOR = "#7030A0"
const val DEFAULT_AUTO_CALL_SPEED = 5
const val DEFAULT_BOARD_TEXT_SCALE = 1f
val AUTO_CALL_SPEED_RANGE = 1..10
/**
* Board number sizes, as a multiplier on the base size. The rungs
* need to reach genuinely large — this replaces the wrapper-era
* textZoom pin as the in-app size control.
*/
val BOARD_TEXT_SCALES = listOf(0.9f, 1f, 1.15f, 1.3f)
val HEX6 = Regex("^#[0-9a-fA-F]{6}$")
}
}
/** Storage values match the web strings ("auto" | "light" | "dark"). */
enum class ThemeSetting(val storageValue: String) {
AUTO("auto"),
LIGHT("light"),
DARK("dark");
companion object {
fun fromStorage(value: String?): ThemeSetting? =
entries.firstOrNull { it.storageValue == value }
}
}
/** Storage values match the web strings ("player" | "master" | "both"). */
enum class AppMode(val storageValue: String) {
PLAYER("player"),
MASTER("master"),
BOTH("both");
companion object {
fun fromStorage(value: String?): AppMode? =
entries.firstOrNull { it.storageValue == value }
}
}
@@ -0,0 +1,114 @@
package com.miti99.loto.settings
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import java.io.IOException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
/**
* DataStore-backed settings store. Every field is validated independently on
* read and falls back to its own default (the web's per-field `valid*` ??
* default pattern) — a bad value never wholesale-resets the rest.
*
* @param validVoiceIds allowlist from the audio manifest
* @param defaultVoiceId first manifest entry
*/
class SettingsRepository(
private val dataStore: DataStore<Preferences>,
private val validVoiceIds: Set<String>,
private val defaultVoiceId: String,
) {
private object Keys {
val EMPTY_CELL_COLOR = stringPreferencesKey("emptyCellColor")
val THEME = stringPreferencesKey("theme")
val MODE = stringPreferencesKey("mode")
val AUTO_CALL_ENABLED = booleanPreferencesKey("autoCallEnabled")
val AUTO_CALL_SPEED = intPreferencesKey("autoCallSpeed")
val VOICE_ENABLED_MASTER = booleanPreferencesKey("voiceEnabledMaster")
val VOICE_ENABLED_PLAYER = booleanPreferencesKey("voiceEnabledPlayer")
val VOICE_WAITING_NUMBER = booleanPreferencesKey("voiceWaitingNumber")
val VOICE = stringPreferencesKey("voice")
val BOARD_TEXT_SCALE = floatPreferencesKey("boardTextScale")
}
/** Defaults with the manifest-derived voice filled in. */
val defaults: Settings = Settings(voice = defaultVoiceId)
// IO failures fall back to defaults instead of failing the app-scoped
// stateIn collector (the web swallows every localStorage read error).
val settingsFlow: Flow<Settings> = dataStore.data
.catch { if (it is IOException) emit(emptyPreferences()) else throw it }
.map { prefs -> toSettings(prefs) }
private fun toSettings(prefs: Preferences): Settings = Settings(
emptyCellColor = prefs[Keys.EMPTY_CELL_COLOR]
?.takeIf { Settings.HEX6.matches(it) }
?: Settings.DEFAULT_EMPTY_CELL_COLOR,
theme = ThemeSetting.fromStorage(prefs[Keys.THEME]) ?: ThemeSetting.AUTO,
mode = AppMode.fromStorage(prefs[Keys.MODE]) ?: AppMode.PLAYER,
autoCallEnabled = prefs[Keys.AUTO_CALL_ENABLED] ?: false,
autoCallSpeed = prefs[Keys.AUTO_CALL_SPEED]
?.takeIf { it in Settings.AUTO_CALL_SPEED_RANGE }
?: Settings.DEFAULT_AUTO_CALL_SPEED,
voiceEnabledMaster = prefs[Keys.VOICE_ENABLED_MASTER] ?: true,
voiceEnabledPlayer = prefs[Keys.VOICE_ENABLED_PLAYER] ?: false,
voiceWaitingNumber = prefs[Keys.VOICE_WAITING_NUMBER] ?: false,
voice = prefs[Keys.VOICE]
?.takeIf { validVoiceIds.contains(it) }
?: defaultVoiceId,
boardTextScale = prefs[Keys.BOARD_TEXT_SCALE]
?.takeIf { scale -> Settings.BOARD_TEXT_SCALES.any { it == scale } }
?: Settings.DEFAULT_BOARD_TEXT_SCALE,
)
suspend fun setEmptyCellColor(value: String) =
write { it[Keys.EMPTY_CELL_COLOR] = value }
suspend fun setTheme(value: ThemeSetting) =
write { it[Keys.THEME] = value.storageValue }
suspend fun setMode(value: AppMode) =
write { it[Keys.MODE] = value.storageValue }
suspend fun setAutoCallEnabled(value: Boolean) =
write { it[Keys.AUTO_CALL_ENABLED] = value }
suspend fun setAutoCallSpeed(value: Int) =
write { it[Keys.AUTO_CALL_SPEED] = value }
suspend fun setVoiceEnabledMaster(value: Boolean) =
write { it[Keys.VOICE_ENABLED_MASTER] = value }
suspend fun setVoiceEnabledPlayer(value: Boolean) =
write { it[Keys.VOICE_ENABLED_PLAYER] = value }
suspend fun setVoiceWaitingNumber(value: Boolean) =
write { it[Keys.VOICE_WAITING_NUMBER] = value }
suspend fun setVoice(value: String) =
write { it[Keys.VOICE] = value }
suspend fun setBoardTextScale(value: Float) =
write { it[Keys.BOARD_TEXT_SCALE] = value }
/** Return every setting to its default (and persist that). */
suspend fun reset() = write { it.clear() }
/** Write failures are swallowed — the app keeps working in-memory. */
private suspend fun write(block: (MutablePreferences) -> Unit) {
try {
dataStore.edit(block)
} catch (_: IOException) {
}
}
}
@@ -0,0 +1,114 @@
package com.miti99.loto.state
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.stringPreferencesKey
import com.miti99.loto.game.CardGenerator
import java.io.IOException
import kotlinx.coroutines.flow.first
/** The player's persisted round: card, marks, and explicit unticks. */
data class PlayerRoundState(
val grid: List<List<Int>>,
val crossed: List<List<Boolean>>,
val manualUnticks: Set<Int>,
)
/** The master's persisted round: draw history and the rest of the deck. */
data class MasterRoundState(
val called: List<Int>,
val remaining: List<Int>,
)
/**
* Round-state persistence so a process death or reboot restores the exact
* game — the native counterpart of the web's `loto_*`/`loto_master`
* localStorage keys. Matrices are stored as compact CSV/bitmask strings.
* Both failure classes fall back instead of crashing (the web's
* try/catch-everything localStorage posture): malformed payloads
* shape-validate to "no saved round", and IO errors are swallowed on write
* and read as empty state on load.
*/
class GameStateRepository(private val dataStore: DataStore<Preferences>) {
private object Keys {
val PLAYER_GRID = stringPreferencesKey("player_grid")
val PLAYER_CROSSED = stringPreferencesKey("player_crossed")
val PLAYER_MANUAL_UNTICKS = stringPreferencesKey("player_manual_unticks")
val MASTER_CALLED = stringPreferencesKey("master_called")
val MASTER_REMAINING = stringPreferencesKey("master_remaining")
}
private val cells = CardGenerator.NUM_ROWS * CardGenerator.NUM_COLS
suspend fun loadPlayerState(): PlayerRoundState? {
val prefs = readPrefs()
val grid = parseGrid(prefs[Keys.PLAYER_GRID]) ?: return null
val crossed = parseCrossed(prefs[Keys.PLAYER_CROSSED])
?: grid.map { row -> row.map { false } }
val unticks = parseNumberList(prefs[Keys.PLAYER_MANUAL_UNTICKS])?.toSet() ?: emptySet()
return PlayerRoundState(grid, crossed, unticks)
}
suspend fun savePlayerState(state: PlayerRoundState) {
write { prefs ->
prefs[Keys.PLAYER_GRID] = state.grid.flatten().joinToString(",")
prefs[Keys.PLAYER_CROSSED] =
state.crossed.flatten().joinToString("") { if (it) "1" else "0" }
prefs[Keys.PLAYER_MANUAL_UNTICKS] = state.manualUnticks.sorted().joinToString(",")
}
}
suspend fun loadMasterState(): MasterRoundState? {
val prefs = readPrefs()
val called = parseNumberList(prefs[Keys.MASTER_CALLED]) ?: return null
val remaining = parseNumberList(prefs[Keys.MASTER_REMAINING]) ?: return null
return MasterRoundState(called, remaining)
}
suspend fun saveMasterState(state: MasterRoundState) {
write { prefs ->
prefs[Keys.MASTER_CALLED] = state.called.joinToString(",")
prefs[Keys.MASTER_REMAINING] = state.remaining.joinToString(",")
}
}
private suspend fun readPrefs(): Preferences = try {
dataStore.data.first()
} catch (_: IOException) {
emptyPreferences()
}
private suspend fun write(block: (MutablePreferences) -> Unit) {
try {
dataStore.edit(block)
} catch (_: IOException) {
}
}
/** 81 comma-separated ints, row-major; cell values 0 (empty) or 1..90. */
private fun parseGrid(raw: String?): List<List<Int>>? {
if (raw.isNullOrEmpty()) return null
val nums = raw.split(",").map { it.toIntOrNull() ?: return null }
if (nums.size != cells || nums.any { it !in 0..90 }) return null
return nums.chunked(CardGenerator.NUM_COLS)
}
/** 81-char row-major bitmask of '0'/'1'. */
private fun parseCrossed(raw: String?): List<List<Boolean>>? {
if (raw == null || raw.length != cells || raw.any { it != '0' && it != '1' }) return null
return raw.map { it == '1' }.chunked(CardGenerator.NUM_COLS)
}
/** Comma-separated ints, each in 1..90. Empty string = empty list. */
private fun parseNumberList(raw: String?): List<Int>? {
if (raw == null) return null
if (raw.isEmpty()) return emptyList()
val nums = raw.split(",").map { it.toIntOrNull() ?: return null }
if (nums.any { it !in 1..90 }) return null
return nums
}
}
@@ -0,0 +1,39 @@
package com.miti99.loto.state
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.miti99.loto.LotoApplication
/**
* Manual-DI ViewModel factory over the [LotoApplication] singletons — the
* app is small enough that a DI framework would cost more than it saves.
*/
class LotoViewModelFactory(private val app: LotoApplication) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = when {
modelClass.isAssignableFrom(PlayerBoardViewModel::class.java) ->
PlayerBoardViewModel(
repository = app.gameStateRepository,
masterStore = app.masterStore,
settings = app.settingsState,
voicePlayer = app.voicePlayer,
) as T
modelClass.isAssignableFrom(MasterPanelViewModel::class.java) ->
MasterPanelViewModel(
masterStore = app.masterStore,
settings = app.settingsState,
voicePlayer = app.voicePlayer,
) as T
modelClass.isAssignableFrom(SettingsViewModel::class.java) ->
SettingsViewModel(
repository = app.settingsRepository,
settings = app.settingsState,
voices = app.voices,
) as T
else -> throw IllegalArgumentException("Unknown ViewModel class: $modelClass")
}
}
@@ -0,0 +1,115 @@
package com.miti99.loto.state
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.miti99.loto.audio.VoicePlayerApi
import com.miti99.loto.audio.playNumber
import com.miti99.loto.settings.Settings
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
/**
* State and commands for the master (caller) panel, ported from
* `web/src/lib/MasterPanel.svelte`. Deck data lives in the shared
* [MasterStore]; this ViewModel adds the auto-call ticker, voice
* announcements, and the countdown reset signal.
*/
class MasterPanelViewModel(
private val masterStore: MasterStore,
private val settings: StateFlow<Settings>,
private val voicePlayer: VoicePlayerApi,
) : ViewModel() {
/** Master round data (called history + remaining deck). */
val masterState: StateFlow<MasterRoundState> = masterStore.state
private val _autoRunning = MutableStateFlow(false)
val autoRunning: StateFlow<Boolean> = _autoRunning
// Whether the UI is visible. The web's ticker paused for free when the
// page hid (throttled/paused WebView); a native ViewModel outlives
// onStop, so the Activity drives this flag. Backgrounding pauses the
// ticker without flipping autoRunning — it resumes on return.
private val _foreground = MutableStateFlow(true)
/**
* Bumped on each draw — signals the countdown ring to re-baseline
* (the web's `tickCount`).
*/
private val _tickKey = MutableStateFlow(0)
val tickKey: StateFlow<Int> = _tickKey
init {
// Auto-call ticker. collectLatest tears down and re-arms the loop
// whenever autoRunning or the relevant settings change — the native
// equivalent of the web's single $effect over
// (autoRunning, autoCallEnabled, autoCallSpeed). Disabling the auto
// setting mid-run also stops the run.
viewModelScope.launch {
combine(_autoRunning, _foreground, settings) { running, foreground, s ->
Quad(running, foreground, s.autoCallEnabled, s.autoCallSpeed)
}
.distinctUntilChanged()
.collectLatest { (running, foreground, enabled, speed) ->
if (!running) return@collectLatest
if (!enabled) {
_autoRunning.value = false
return@collectLatest
}
if (!foreground) return@collectLatest
while (true) {
delay(speed * 1000L)
if (masterStore.state.value.remaining.isEmpty()) {
_autoRunning.value = false
break
}
drawNext()
}
}
}
}
/** Start a fresh round (the UI owns the confirmation dialog). */
fun newGame() {
voicePlayer.cancel()
_autoRunning.value = false
masterStore.startNewGame()
}
/** Draw the next number and announce it when the master voice is on. */
fun drawNext() {
val next = masterStore.drawNext() ?: return
_tickKey.value += 1
if (settings.value.voiceEnabledMaster) {
voicePlayer.playNumber(next)
}
}
/** Toggle the auto-call run; no-op when the deck is exhausted. */
fun toggleAuto() {
if (masterStore.state.value.remaining.isEmpty()) return
_autoRunning.value = !_autoRunning.value
}
/** Driven by the Activity's onStart/onStop (see the ticker note above). */
fun setForeground(foreground: Boolean) {
_foreground.value = foreground
if (!foreground) voicePlayer.cancel()
}
override fun onCleared() {
voicePlayer.cancel()
}
}
private data class Quad(
val running: Boolean,
val foreground: Boolean,
val enabled: Boolean,
val speed: Int,
)
@@ -0,0 +1,52 @@
package com.miti99.loto.state
import com.miti99.loto.game.DrawDeck
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
/**
* Shared reactive state for the master draw deck — the native counterpart of
* `web/src/lib/master-store.svelte.js`. Application-scoped so the player side
* reads the full `called` history directly (no single-slot bus that loses
* history). Persists through [GameStateRepository] on every mutation.
*/
class MasterStore(
private val repository: GameStateRepository,
private val scope: CoroutineScope,
private val deck: DrawDeck = DrawDeck(),
) {
private val _state = MutableStateFlow(MasterRoundState(emptyList(), emptyList()))
val state: StateFlow<MasterRoundState> = _state
/** Restore the persisted round, if any. Call once on startup. */
suspend fun restore() {
val saved = repository.loadMasterState() ?: return
deck.restore(saved.called, saved.remaining)
_state.value = saved
}
/** Start a fresh round: empty called, full shuffled remaining. */
fun startNewGame() {
deck.startNewGame()
publishAndSave()
}
/**
* Draw the next number from remaining into called.
* @return the drawn number, or null if exhausted.
*/
fun drawNext(): Int? {
val next = deck.drawNext() ?: return null
publishAndSave()
return next
}
private fun publishAndSave() {
val snapshot = MasterRoundState(deck.called, deck.remaining)
_state.value = snapshot
scope.launch { repository.saveMasterState(snapshot) }
}
}
@@ -0,0 +1,354 @@
package com.miti99.loto.state
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.miti99.loto.audio.VoicePlayerApi
import com.miti99.loto.audio.playBingo
import com.miti99.loto.audio.playWaiting
import com.miti99.loto.game.CardGenerator
import com.miti99.loto.game.PlayerAutoCross
import com.miti99.loto.game.PlayerCard
import com.miti99.loto.settings.AppMode
import com.miti99.loto.settings.Settings
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
/**
* Immutable UI state of the player board. Derived fields (row completeness,
* waiting cells, section rings) are recomputed on every publish, mirroring
* the web's `$derived` blocks in `PlayerBoard.svelte`.
*/
data class PlayerUiState(
val grid: List<List<Int>>? = null,
val crossed: List<List<Boolean>> = emptyList(),
val rowComplete: List<Boolean> = emptyList(),
/** Cells holding the awaited number of a waiting row (drives the pulse). */
val waitingCells: Set<PlayerCard.Cell> = emptySet(),
/** Per 3-row section (Tân Tân card bands): any row inside is waiting. */
val sectionWaiting: List<Boolean> = listOf(false, false, false),
/** Transient "Chờ N" chip text, auto-dismissed after 5s. */
val toast: String? = null,
val showCongrats: Boolean = false,
/** 1-based row number shown in the Kinh modal. */
val congratsRow: Int = 0,
/** 1 = normal, 2 = confetti celebration. */
val celebrationTier: Int = 1,
)
/**
* State and commands for the player board, ported from
* `web/src/lib/PlayerBoard.svelte`: cell toggling with manual-untick
* tracking, master auto-cross replay, chờ/kinh detection with voice + toast,
* and round persistence.
*/
class PlayerBoardViewModel(
private val repository: GameStateRepository,
private val masterStore: MasterStore,
private val settings: StateFlow<Settings>,
private val voicePlayer: VoicePlayerApi,
) : ViewModel() {
private val _uiState = MutableStateFlow(PlayerUiState())
val uiState: StateFlow<PlayerUiState> = _uiState
private var grid: List<List<Int>>? = null
private var crossed: List<List<Boolean>> = emptyList()
private val manualUnticks = mutableSetOf<Int>()
private val celebratedRows = mutableSetOf<Int>()
private val notifiedWaitingRows = mutableSetOf<Int>()
// How many entries of the master's called[] were already replayed.
// Advances strictly even on no-op passes so a single draw never
// re-fires. Stays 0 across restore so the full history replays
// idempotently (already-crossed cells are skipped).
private var lastHandledIndex = 0
// Tracks called.length across emissions to detect a master "Ván mới"
// (length transitions >0 → 0), which force-clears player marks in both
// mode per locked product decision.
private var prevCalledLen = 0
private var toastJob: Job? = null
init {
viewModelScope.launch {
restore()
// Collect after restore so the replay sees the loaded grid.
masterStore.state.collect { master -> onMasterState(master) }
}
}
private suspend fun restore() {
val saved = repository.loadPlayerState() ?: return
grid = saved.grid
crossed = saved.crossed
manualUnticks.clear()
manualUnticks.addAll(saved.manualUnticks)
// Seed the row trackers so restoring a finished/waiting row does not
// re-announce it.
celebratedRows.clear()
notifiedWaitingRows.clear()
for (i in saved.grid.indices) {
if (PlayerCard.isRowComplete(saved.grid, saved.crossed, i)) celebratedRows.add(i)
if (PlayerCard.getWaitingNumber(saved.grid, saved.crossed, i) != null) {
notifiedWaitingRows.add(i)
}
}
publish()
}
private fun onMasterState(master: MasterRoundState) {
val len = master.called.size
val prev = prevCalledLen
prevCalledLen = len
// Master "Ván mới": force-clear player marks in both mode. Keyed on
// any shrink (not only ==0) so a reset conflated with the new
// round's first draws is still detected; the fall-through replay
// then applies those draws to the cleared board.
if (prev > 0 && len < prev && settings.value.mode == AppMode.BOTH && grid != null) {
crossed = grid!!.map { row -> row.map { false } }
manualUnticks.clear()
lastHandledIndex = 0
celebratedRows.clear()
notifiedWaitingRows.clear()
persist()
publish()
}
val result = PlayerAutoCross.applyMasterCalls(
grid = grid,
crossed = crossed,
called = master.called,
lastHandledIndex = lastHandledIndex,
manualUnticks = manualUnticks,
mode = settings.value.mode,
)
lastHandledIndex = result.lastHandledIndex
if (result.changed) {
crossed = result.crossed
persist()
detectRowEvents()
publish()
}
}
/** Generate a fresh card (the UI owns the confirmation dialog). */
fun generate() {
voicePlayer.cancel()
val newGrid = CardGenerator.generateGrid()
var newCrossed = newGrid.map { row -> row.map { false } }
manualUnticks.clear()
// Replay the master's called[] onto the fresh grid so the host
// doesn't restart from zero when regenerating mid-game (locked
// decision); outside both mode just advance the cursor.
if (settings.value.mode == AppMode.BOTH) {
val result = PlayerAutoCross.applyMasterCalls(
grid = newGrid,
crossed = newCrossed,
called = masterStore.state.value.called,
lastHandledIndex = 0,
manualUnticks = manualUnticks,
mode = AppMode.BOTH,
)
newCrossed = result.crossed
lastHandledIndex = result.lastHandledIndex
} else {
lastHandledIndex = masterStore.state.value.called.size
}
grid = newGrid
crossed = newCrossed
celebratedRows.clear()
notifiedWaitingRows.clear()
dismissToast()
persist()
publish(showCongrats = false)
// The replay itself can complete a row or land one in chờ — the web
// gets this re-check for free from its reactive effect.
detectRowEvents()
}
/** Clear all marks (the UI owns the confirmation dialog). */
fun clearMarks() {
val g = grid ?: return
voicePlayer.cancel()
var cleared = g.map { row -> row.map { false } }
manualUnticks.clear()
// In both mode, immediately replay the master's called[] (locked
// decision: clear → re-cross all currently-called numbers).
if (settings.value.mode == AppMode.BOTH) {
val result = PlayerAutoCross.applyMasterCalls(
grid = g,
crossed = cleared,
called = masterStore.state.value.called,
lastHandledIndex = 0,
manualUnticks = manualUnticks,
mode = AppMode.BOTH,
)
cleared = result.crossed
lastHandledIndex = result.lastHandledIndex
} else {
lastHandledIndex = masterStore.state.value.called.size
}
crossed = cleared
celebratedRows.clear()
notifiedWaitingRows.clear()
dismissToast()
persist()
publish(showCongrats = false)
// See generate(): the clear-replay can immediately re-complete rows.
detectRowEvents()
}
/** Toggle one cell (haptics live in the UI layer). */
fun onCellClick(row: Int, col: Int) {
val g = grid ?: return
val num = g[row][col]
if (num <= 0) return
val wasCrossed = crossed.getOrNull(row)?.getOrNull(col) == true
// Track manual unticks of called numbers so future regen/clear
// replays skip them; re-ticking removes the suppression. Uncalled
// numbers are not tracked — auto-cross only acts on called ones.
if (masterStore.state.value.called.contains(num)) {
if (wasCrossed) manualUnticks.add(num) else manualUnticks.remove(num)
}
crossed = crossed.mapIndexed { ri, r ->
if (ri == row) r.mapIndexed { ci, v -> if (ci == col) !v else v } else r
}
persist()
detectRowEvents()
publish()
}
fun dismissToast() {
toastJob?.cancel()
toastJob = null
if (_uiState.value.toast != null) {
_uiState.value = _uiState.value.copy(toast = null)
}
}
fun dismissCongrats() {
if (_uiState.value.showCongrats) {
_uiState.value = _uiState.value.copy(showCongrats = false)
}
}
override fun onCleared() {
voicePlayer.cancel()
}
/**
* Detect newly completed and newly waiting rows, mirroring the web's
* two-pass $effect: at most one Kinh modal per change, then toast +
* voice for each newly waiting row.
*/
private fun detectRowEvents() {
val g = grid ?: return
if (crossed.isEmpty()) return
val s = settings.value
// The master takes over announcer duties in both mode, so its voice
// flag also drives Chờ/Kinh. Solo players keep their own flag.
val announce = s.voiceEnabledPlayer || (s.voiceEnabledMaster && s.mode == AppMode.BOTH)
// Pass 1: at most one bingo popup per change.
for (i in g.indices) {
if (i in celebratedRows || !PlayerCard.isRowComplete(g, crossed, i)) continue
celebratedRows.add(i)
notifiedWaitingRows.add(i)
// Tier 2 confetti: 2nd bingo, OR 1st bingo while another row is
// one cell away — the old "3+ bingos" threshold rarely fired on
// a 9-row card, leaving most wins under-celebrated.
val hasActiveCho = g.indices.any { r ->
r !in celebratedRows && PlayerCard.getWaitingNumber(g, crossed, r) != null
}
val tier = if (celebratedRows.size >= 2 || hasActiveCho) 2 else 1
_uiState.value = _uiState.value.copy(
showCongrats = true,
congratsRow = i + 1,
celebrationTier = tier,
)
if (announce) voicePlayer.playBingo()
break
}
// Pass 2: update waiting state for every non-celebrated row.
for (i in g.indices) {
if (i in celebratedRows) continue
val waitNum = PlayerCard.getWaitingNumber(g, crossed, i)
if (waitNum != null && i !in notifiedWaitingRows) {
notifiedWaitingRows.add(i)
showToast("Chờ $waitNum")
if (announce) {
voicePlayer.playWaiting(
waitNum,
voiceWaitingNumber = s.voiceWaitingNumber,
modeIsBoth = s.mode == AppMode.BOTH,
)
}
} else if (waitNum == null && i in notifiedWaitingRows) {
notifiedWaitingRows.remove(i)
}
}
}
private fun showToast(message: String) {
toastJob?.cancel()
_uiState.value = _uiState.value.copy(toast = message)
toastJob = viewModelScope.launch {
delay(TOAST_DURATION_MS)
_uiState.value = _uiState.value.copy(toast = null)
}
}
private fun persist() {
val g = grid ?: return
val snapshot = PlayerRoundState(g, crossed, manualUnticks.toSet())
viewModelScope.launch { repository.savePlayerState(snapshot) }
}
private fun publish(showCongrats: Boolean? = null) {
val g = grid
if (g == null || crossed.isEmpty()) {
_uiState.value = _uiState.value.copy(
grid = g,
crossed = crossed,
rowComplete = emptyList(),
waitingCells = emptySet(),
sectionWaiting = listOf(false, false, false),
showCongrats = showCongrats ?: _uiState.value.showCongrats,
)
return
}
val rowComplete = g.indices.map { PlayerCard.isRowComplete(g, crossed, it) }
val waitingRows = g.indices.map { r ->
!rowComplete[r] && PlayerCard.getWaitingNumber(g, crossed, r) != null
}
val waitingCells = buildSet {
for (r in g.indices) {
if (rowComplete[r]) continue
val num = PlayerCard.getWaitingNumber(g, crossed, r) ?: continue
val c = g[r].indexOf(num)
if (c >= 0) add(PlayerCard.Cell(r, c))
}
}
// Tân Tân physical card: 3 stacked 3-row sections; a section band
// rings while any of its rows is waiting.
val sectionWaiting = listOf(0, 3, 6).map { start ->
(start until start + 3).any { waitingRows.getOrNull(it) == true }
}
_uiState.value = _uiState.value.copy(
grid = g,
crossed = crossed,
rowComplete = rowComplete,
waitingCells = waitingCells,
sectionWaiting = sectionWaiting,
showCongrats = showCongrats ?: _uiState.value.showCongrats,
)
}
companion object {
const val TOAST_DURATION_MS = 5_000L
}
}
@@ -0,0 +1,40 @@
package com.miti99.loto.state
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.miti99.loto.audio.Voice
import com.miti99.loto.settings.AppMode
import com.miti99.loto.settings.Settings
import com.miti99.loto.settings.SettingsRepository
import com.miti99.loto.settings.ThemeSetting
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
/**
* Bridges the settings sheet UI to [SettingsRepository]. Reads come from the
* app-scoped [settings] state; writes go through the repository (validation
* happens on read, mirroring the web store).
*/
class SettingsViewModel(
private val repository: SettingsRepository,
val settings: StateFlow<Settings>,
/** Bundled voices for the voice picker. */
val voices: List<Voice>,
) : ViewModel() {
fun setEmptyCellColor(value: String) = update { repository.setEmptyCellColor(value) }
fun setTheme(value: ThemeSetting) = update { repository.setTheme(value) }
fun setMode(value: AppMode) = update { repository.setMode(value) }
fun setAutoCallEnabled(value: Boolean) = update { repository.setAutoCallEnabled(value) }
fun setAutoCallSpeed(value: Int) = update { repository.setAutoCallSpeed(value) }
fun setVoiceEnabledMaster(value: Boolean) = update { repository.setVoiceEnabledMaster(value) }
fun setVoiceEnabledPlayer(value: Boolean) = update { repository.setVoiceEnabledPlayer(value) }
fun setVoiceWaitingNumber(value: Boolean) = update { repository.setVoiceWaitingNumber(value) }
fun setVoice(value: String) = update { repository.setVoice(value) }
fun setBoardTextScale(value: Float) = update { repository.setBoardTextScale(value) }
fun reset() = update { repository.reset() }
private fun update(block: suspend () -> Unit) {
viewModelScope.launch { block() }
}
}
@@ -0,0 +1,13 @@
package com.miti99.loto.ui
import androidx.compose.ui.graphics.Color
/**
* Parse a hex6 setting value ("#RRGGBB") into a Compose color. The settings
* layer already validates the format; the fallback guards direct callers.
*/
fun String.toComposeColor(): Color = try {
Color(android.graphics.Color.parseColor(this))
} catch (_: IllegalArgumentException) {
Color(0xFF7030A0)
}
@@ -0,0 +1,26 @@
package com.miti99.loto.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalView
import com.miti99.loto.settings.AppMode
/**
* Whether the screen must stay awake: a master round is still being drawn
* (auto-call advances with no touch input, so the display would otherwise
* sleep mid-round). Keyed on remaining — a finished board releases the
* screen — and never held in player-only mode, where the player's own taps
* keep the screen alive (web `wake-lock` parity).
*/
fun shouldKeepScreenOn(mode: AppMode, remainingCount: Int): Boolean =
mode != AppMode.PLAYER && remainingCount > 0
/** Holds the window's keep-screen-on flag while [enabled]; releases on leave. */
@Composable
fun KeepScreenOn(enabled: Boolean) {
val view = LocalView.current
DisposableEffect(enabled) {
view.keepScreenOn = enabled
onDispose { view.keepScreenOn = false }
}
}
@@ -0,0 +1,175 @@
package com.miti99.loto.ui
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.net.toUri
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import com.miti99.loto.R
import com.miti99.loto.settings.AppMode
import com.miti99.loto.settings.Settings
import com.miti99.loto.state.MasterPanelViewModel
import com.miti99.loto.state.PlayerBoardViewModel
import com.miti99.loto.ui.master.MasterPanelScreen
import com.miti99.loto.ui.player.PlayerBoardScreen
import com.miti99.loto.ui.theme.LotoTheme
/**
* Page composition, ported from `web/src/routes/+page.svelte`: header,
* player board (mode ≠ master), master section (mode ≠ player), footer —
* one shared scroll, content capped at the web's max-w-2xl.
*/
@Composable
fun LotoAppRoot(
settings: Settings,
playerViewModel: PlayerBoardViewModel,
masterViewModel: MasterPanelViewModel,
settingsButton: @Composable () -> Unit = {},
) {
val palette = LotoTheme.palette
// Hold the screen awake while numbers are still to be called.
val master by masterViewModel.masterState.collectAsState()
KeepScreenOn(enabled = shouldKeepScreenOn(settings.mode, master.remaining.size))
Box(
modifier = Modifier
.fillMaxSize()
.background(palette.background)
.safeDrawingPadding(),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 8.dp, vertical = 16.dp),
) {
Column(modifier = Modifier.widthIn(max = 672.dp)) {
Box(modifier = Modifier.fillMaxWidth()) {
PageHeader()
Box(modifier = Modifier.align(Alignment.TopEnd)) {
settingsButton()
}
}
if (settings.mode != AppMode.MASTER) {
PlayerBoardScreen(viewModel = playerViewModel, settings = settings)
}
if (settings.mode != AppMode.PLAYER) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = if (settings.mode == AppMode.BOTH) 40.dp else 0.dp),
) {
Text(
text = stringResource(R.string.master_heading).uppercase(),
color = palette.masterHeading,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.1.em,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
)
MasterPanelScreen(viewModel = masterViewModel, settings = settings)
}
}
PageFooter()
}
}
}
}
@Composable
private fun PageHeader() {
val palette = LotoTheme.palette
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp),
) {
Text(
text = stringResource(R.string.app_name),
color = palette.title,
fontSize = 48.sp,
fontWeight = FontWeight.Black,
fontStyle = FontStyle.Italic,
letterSpacing = (-1).sp,
)
Row(verticalAlignment = Alignment.CenterVertically) {
HeaderRule()
Text(
text = stringResource(R.string.app_subtitle),
color = palette.subtitle,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 8.dp),
)
HeaderRule()
}
}
}
@Composable
private fun HeaderRule() {
Box(
modifier = Modifier
.width(24.dp)
.height(1.dp)
.background(LotoTheme.palette.subtitle.copy(alpha = 0.6f)),
)
}
@Composable
private fun PageFooter() {
val palette = LotoTheme.palette
val context = LocalContext.current
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().padding(top = 32.dp, bottom = 8.dp),
) {
Text(
text = stringResource(R.string.footer_inspiration),
color = palette.subtitle,
fontSize = 13.sp,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp),
)
Text(
text = stringResource(R.string.footer_credit),
color = palette.subtitle,
fontSize = 13.sp,
modifier = Modifier
.padding(top = 8.dp)
.clickable {
// Opens the browser; the app itself stays offline-only.
runCatching {
context.startActivity(
Intent(Intent.ACTION_VIEW, "https://miti99.com".toUri()),
)
}
},
)
}
}
@@ -0,0 +1,24 @@
package com.miti99.loto.ui
import android.provider.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
/**
* Android's analog of the web's `prefers-reduced-motion`: animations are
* globally disabled when the animator duration scale is 0 (system
* "Remove animations" accessibility setting). Gates decorative pulses,
* confetti, and tap haptics — same events the web suppresses.
*/
@Composable
fun rememberReducedMotion(): Boolean {
val context = LocalContext.current
return remember {
Settings.Global.getFloat(
context.contentResolver,
Settings.Global.ANIMATOR_DURATION_SCALE,
1f,
) == 0f
}
}
@@ -0,0 +1,106 @@
package com.miti99.loto.ui.master
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.miti99.loto.R
import com.miti99.loto.ui.theme.LotoTheme
import kotlin.math.ceil
/**
* Visual countdown ring for auto-call, ported from `AutoCountdown.svelte`.
* Render-only: the ViewModel owns the draw timer; this animation is a
* presentation clock re-baselined on every [tickKey] bump, `running` rising
* edge, or [durationSeconds] change — never a second draw timer.
*/
@Composable
fun AutoCountdown(
running: Boolean,
durationSeconds: Int,
tickKey: Int,
reducedMotion: Boolean,
modifier: Modifier = Modifier,
) {
val palette = LotoTheme.palette
// Wall-clock frame loop rather than an animation: Compose animations
// scale with the system animator duration setting, which would complete
// the tween instantly (and freeze the number) when animations are off.
var progress by remember { mutableFloatStateOf(0f) }
LaunchedEffect(running, durationSeconds, tickKey) {
progress = 0f
if (!running) return@LaunchedEffect
val startNanos = withFrameNanos { it }
val totalNanos = durationSeconds * 1_000_000_000L
while (progress < 1f) {
withFrameNanos { now ->
progress = ((now - startNanos).toFloat() / totalNanos).coerceIn(0f, 1f)
}
}
}
// Clamp to [1, duration] while running — avoids flashing 0 between the
// timer edge and the tickKey bump (same clamp as the web).
val secondsRemaining = if (running) {
ceil(durationSeconds * (1f - progress)).toInt().coerceIn(1, durationSeconds)
} else {
durationSeconds
}
val label = stringResource(R.string.master_countdown, secondsRemaining)
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.size(88.dp)
.semantics { contentDescription = label },
) {
Canvas(modifier = Modifier.fillMaxSize()) {
val stroke = 8.dp.toPx()
val inset = stroke / 2
drawArc(
color = palette.countdownTrack,
startAngle = 0f,
sweepAngle = 360f,
useCenter = false,
style = Stroke(stroke),
topLeft = androidx.compose.ui.geometry.Offset(inset, inset),
size = androidx.compose.ui.geometry.Size(size.width - stroke, size.height - stroke),
)
// Reduced motion keeps a static full ring; the number still
// ticks because the frame loop above is wall-clock based.
val sweep = if (reducedMotion) 360f else 360f * (1f - progress)
drawArc(
color = palette.countdownArc,
startAngle = -90f,
sweepAngle = sweep,
useCenter = false,
style = Stroke(stroke, cap = StrokeCap.Round),
topLeft = androidx.compose.ui.geometry.Offset(inset, inset),
size = androidx.compose.ui.geometry.Size(size.width - stroke, size.height - stroke),
)
}
Text(
text = secondsRemaining.toString(),
color = palette.foreground,
fontSize = 26.sp,
fontWeight = FontWeight.Black,
)
}
}
@@ -0,0 +1,23 @@
package com.miti99.loto.ui.master
/**
* The 11×9 master tracking board, aligned by ones digit — ported from the
* `buildBoard()` module block of `web/src/lib/MasterPanel.svelte`.
* Row = ones digit, col = tens digit. Col 0 holds 1..9, col 8 holds 80..90
* (90 sits alone in row 10, col 8). Empty slots = 0.
*/
object MasterBoardLayout {
const val ROWS = 11
const val COLS = 9
val BOARD: List<List<Int>> = List(ROWS) { row ->
List(COLS) { col ->
when {
row == 10 -> if (col == 8) 90 else 0
row == 0 -> if (col > 0) col * 10 else 0
else -> if (col == 0) row else col * 10 + row
}
}
}
}
@@ -0,0 +1,120 @@
package com.miti99.loto.ui.master
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import com.miti99.loto.R
import com.miti99.loto.settings.Settings
import com.miti99.loto.ui.theme.LotoTheme
import com.miti99.loto.ui.toComposeColor
/**
* Empty-state hero for master mode, ported from `MasterEmptyState.svelte`:
* a ghost mock of the 11×9 tracking grid keeps the page silhouette
* consistent with mid-game.
*/
@Composable
fun MasterEmptyState(modifier: Modifier = Modifier) {
val palette = LotoTheme.palette
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier.fillMaxWidth().padding(vertical = 40.dp),
) {
Column(
modifier = Modifier
.widthIn(max = 320.dp)
.alpha(0.3f)
.clip(RoundedCornerShape(6.dp))
.border(1.dp, palette.buttonSecondaryBorder, RoundedCornerShape(6.dp))
.padding(bottom = 24.dp),
) {
for (rowStart in 0 until 99 step 9) {
Row(modifier = Modifier.fillMaxWidth()) {
for (i in rowStart until rowStart + 9) {
val filled = i % 11 < 9 && i % 7 == 0
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.weight(1f)
.aspectRatio(1f)
.background(
if (filled) palette.cellBg
else Settings.DEFAULT_EMPTY_CELL_COLOR.toComposeColor(),
),
) {
if (filled) {
Text(
text = "${(i * 13) % 90 + 1}",
fontSize = 8.sp,
color = palette.cellText,
)
}
}
}
}
}
}
Text(
text = stringResource(R.string.master_empty_badge).uppercase(),
color = palette.masterHeading,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 0.1.em,
modifier = Modifier
.clip(RoundedCornerShape(50))
.background(palette.tokenBg)
.padding(horizontal = 12.dp, vertical = 4.dp),
)
Text(
text = buildAnnotatedString {
append(stringResource(R.string.master_empty_hint_prefix))
append(" ")
withStyle(
SpanStyle(
color = palette.masterHeading,
fontWeight = FontWeight.SemiBold,
fontStyle = FontStyle.Normal,
),
) {
append("\"" + stringResource(R.string.master_new_game) + "\"")
}
append(" ")
append(stringResource(R.string.master_empty_hint_suffix))
},
color = palette.subtitle,
fontSize = 16.sp,
fontStyle = FontStyle.Italic,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 12.dp),
)
Text(
text = stringResource(R.string.master_empty_ready),
color = palette.subtitle,
fontSize = 14.sp,
modifier = Modifier.padding(top = 6.dp),
)
}
}
@@ -0,0 +1,327 @@
package com.miti99.loto.ui.master
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import com.miti99.loto.R
import com.miti99.loto.settings.Settings
import com.miti99.loto.state.MasterPanelViewModel
import com.miti99.loto.ui.player.ConfirmDialog
import com.miti99.loto.ui.rememberReducedMotion
import com.miti99.loto.ui.theme.CondensedNumberFont
import com.miti99.loto.ui.theme.LotoTheme
import com.miti99.loto.ui.toComposeColor
/**
* The host (quản trò) surface, ported from `MasterPanel.svelte`: controls,
* countdown, current-number hero, called history in draw order, and the
* 11×9 ones-digit tracking board (or the ghost empty state).
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun MasterPanelScreen(
viewModel: MasterPanelViewModel,
settings: Settings,
modifier: Modifier = Modifier,
) {
val palette = LotoTheme.palette
val master by viewModel.masterState.collectAsState()
val autoRunning by viewModel.autoRunning.collectAsState()
val tickKey by viewModel.tickKey.collectAsState()
val reducedMotion = rememberReducedMotion()
var confirmNewGame by remember { mutableStateOf(false) }
val hasGame = master.called.isNotEmpty() || master.remaining.isNotEmpty()
val lastCalled = master.called.lastOrNull()
val heroRequester = remember { BringIntoViewRequester() }
// Keep the hero visible on each draw (the web scrolls it into view).
LaunchedEffect(tickKey) {
if (tickKey > 0 && lastCalled != null) heroRequester.bringIntoView()
}
Column(modifier = modifier.fillMaxWidth()) {
// Controls
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
) {
PillButton(
text = stringResource(R.string.master_new_game),
container = palette.masterNewGame,
onClick = { if (hasGame) confirmNewGame = true else viewModel.newGame() },
)
if (hasGame && master.remaining.isNotEmpty()) {
if (settings.autoCallEnabled) {
PillButton(
text = stringResource(
if (autoRunning) R.string.master_auto_stop
else R.string.master_auto_start,
),
container = if (autoRunning) palette.masterStop else palette.masterDraw,
onClick = { viewModel.toggleAuto() },
)
} else {
PillButton(
text = stringResource(R.string.master_draw),
container = palette.masterDraw,
onClick = { viewModel.drawNext() },
)
}
}
}
if (settings.autoCallEnabled && hasGame && master.remaining.isNotEmpty()) {
Text(
text = stringResource(R.string.master_auto_speed, settings.autoCallSpeed),
color = palette.subtitle,
fontSize = 14.sp,
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
if (autoRunning && master.remaining.isNotEmpty()) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
) {
AutoCountdown(
running = autoRunning,
durationSeconds = settings.autoCallSpeed,
tickKey = tickKey,
reducedMotion = reducedMotion,
)
}
}
// Current-number hero
if (lastCalled != null) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 20.dp)
.bringIntoViewRequester(heroRequester),
) {
Text(
text = stringResource(R.string.master_last_called).uppercase(),
color = palette.subtitle,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 0.2.em,
modifier = Modifier.padding(bottom = 8.dp),
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(144.dp)
.clip(CircleShape)
.background(palette.tokenBg)
.border(7.dp, palette.tokenBorder, CircleShape),
) {
Text(
text = lastCalled.toString(),
color = palette.tokenText,
fontFamily = CondensedNumberFont,
fontSize = 64.sp,
fontWeight = FontWeight.Black,
)
}
Text(
text = stringResource(
R.string.master_called_count,
master.called.size,
master.remaining.size,
),
color = palette.subtitle,
fontSize = 14.sp,
modifier = Modifier.padding(top = 10.dp),
)
}
}
// Called history (draw order)
if (master.called.isNotEmpty()) {
Text(
text = stringResource(R.string.master_call_order),
color = palette.subtitle,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(bottom = 6.dp),
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth().padding(bottom = 20.dp),
) {
for (num in master.called) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(palette.tokenBg)
.border(2.dp, palette.tokenBorder, CircleShape),
) {
Text(
text = num.toString(),
color = palette.tokenText,
fontSize = 16.sp,
fontWeight = FontWeight.Black,
)
}
}
}
}
if (hasGame) {
MasterBoard(
called = master.called,
lastCalled = lastCalled,
emptyCellColor = settings.emptyCellColor.toComposeColor(),
textScale = settings.boardTextScale,
)
} else {
MasterEmptyState()
}
}
if (confirmNewGame) {
ConfirmDialog(
message = stringResource(R.string.master_confirm_new_game),
onConfirm = {
confirmNewGame = false
viewModel.newGame()
},
onDismiss = { confirmNewGame = false },
)
}
}
@Composable
private fun PillButton(text: String, container: Color, onClick: () -> Unit) {
Button(
onClick = onClick,
shape = RoundedCornerShape(50),
colors = ButtonDefaults.buttonColors(containerColor = container, contentColor = Color.White),
) {
Text(
text = text,
fontWeight = FontWeight.SemiBold,
fontSize = 17.sp,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
)
}
}
/** 11×9 ones-digit tracking board. Called numbers get the cream/sky token; the last draw wears a red ring. */
@Composable
private fun MasterBoard(
called: List<Int>,
lastCalled: Int?,
emptyCellColor: Color,
textScale: Float,
) {
val palette = LotoTheme.palette
// number -> 1-based draw order for the corner badges.
val callOrder = remember(called) { called.withIndex().associate { (i, n) -> n to i + 1 } }
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.border(1.dp, palette.boardCellBorder, RoundedCornerShape(16.dp))
.background(palette.boardBg),
) {
for (row in MasterBoardLayout.BOARD) {
Row(modifier = Modifier.fillMaxWidth()) {
for (num in row) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.weight(1f)
.aspectRatio(1f)
.background(if (num > 0) palette.boardBg else emptyCellColor)
.border(0.5.dp, palette.boardCellBorder),
) {
if (num > 0) {
val order = callOrder[num]
val isCalled = order != null
val isLast = isCalled && num == lastCalled
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxSize(0.82f)
.scale(if (isLast) 1.1f else 1f)
.clip(CircleShape)
.background(if (isCalled) palette.tokenBg else palette.tokenUncalledBg)
.border(
width = if (isLast) 3.dp else 2.dp,
color = when {
isLast -> palette.lastCalledRing
isCalled -> palette.tokenBorder
else -> palette.tokenUncalledBorder
},
shape = CircleShape,
),
) {
Text(
text = num.toString(),
color = if (isCalled) palette.tokenText else palette.tokenUncalledText,
fontSize = (14 * textScale).sp,
fontWeight = FontWeight.Black,
maxLines = 1,
)
}
if (isCalled) {
Text(
text = order.toString(),
color = palette.subtitle,
fontSize = 8.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(top = 1.dp, end = 2.dp),
)
}
}
}
}
}
}
}
}
@@ -0,0 +1,68 @@
package com.miti99.loto.ui.player
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.sp
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
/**
* Falling-emoji burst for tier-2 celebrations, ported from the web's
* `.confetti` keyframes: 12 particles, staggered delays, full-height fall
* with rotation over 1.6s. One shared progress clock drives all particles —
* no per-particle animators. Hội-chợ flavour set (lantern, bamboo,
* chopsticks) so it reads as a Vietnamese fair, not a generic party.
*/
private val CONFETTI_EMOJI = listOf("🎊", "✨", "🎉", "🥳", "🥢", "🎋", "🏮")
private const val PARTICLES = 12
private const val FALL_MS = 1600
private const val MAX_DELAY_MS = 400
@Composable
fun ConfettiOverlay(modifier: Modifier = Modifier) {
var size by remember { mutableStateOf(IntSize.Zero) }
val clock = remember { Animatable(0f) }
LaunchedEffect(Unit) {
clock.animateTo(
targetValue = 1f,
animationSpec = tween(FALL_MS + MAX_DELAY_MS, easing = LinearEasing),
)
}
Box(modifier = modifier.fillMaxSize().onSizeChanged { size = it }) {
val elapsed = clock.value * (FALL_MS + MAX_DELAY_MS)
for (i in 0 until PARTICLES) {
// Same deterministic spread the web uses.
val xFraction = ((i * 8.3f + (i % 3) * 11f) % 100f) / 100f
val delay = (i * 37) % MAX_DELAY_MS
val rotation = ((i * 67) % 360).toFloat()
val sizeSp = 24 + ((i * 13) % 11) * 1.6f
val t = ((elapsed - delay) / FALL_MS).coerceIn(0f, 1f)
if (t <= 0f) continue
Text(
text = CONFETTI_EMOJI[i % CONFETTI_EMOJI.size],
fontSize = sizeSp.sp,
modifier = Modifier.graphicsLayer {
translationX = xFraction * size.width
translationY = (-0.1f + 1.2f * t) * size.height
rotationZ = rotation * t
alpha = when {
t < 0.1f -> t / 0.1f
else -> 0.85f
}
},
)
}
}
}
@@ -0,0 +1,95 @@
package com.miti99.loto.ui.player
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import com.miti99.loto.R
import com.miti99.loto.ui.theme.LotoTheme
/**
* The "Kinh!" (row win) celebration modal, ported from PlayerBoard.svelte.
* Back press and backdrop tap dismiss it (Dialog's onDismissRequest).
*/
@Composable
fun KinhDialog(
congratsRow: Int,
onDismiss: () -> Unit,
) {
val palette = LotoTheme.palette
Dialog(onDismissRequest = onDismiss) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(24.dp))
.background(palette.dialogBg)
.padding(32.dp),
) {
Text(text = "🎉", fontSize = 48.sp)
Text(
text = stringResource(R.string.kinh_title),
color = palette.kinhTitle,
fontSize = 36.sp,
fontWeight = FontWeight.Black,
modifier = Modifier.padding(top = 8.dp),
)
Text(
text = stringResource(R.string.kinh_row_prefix),
color = palette.dialogText,
fontSize = 16.sp,
modifier = Modifier.padding(top = 12.dp),
)
Text(
text = congratsRow.toString(),
color = palette.kinhRow,
fontSize = 56.sp,
fontWeight = FontWeight.Black,
modifier = Modifier.padding(vertical = 4.dp),
)
Text(
text = stringResource(R.string.kinh_row_suffix),
color = palette.dialogText,
fontSize = 16.sp,
)
Text(
text = stringResource(R.string.kinh_shout),
color = palette.subtitle,
fontSize = 14.sp,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 6.dp),
)
Button(
onClick = onDismiss,
shape = RoundedCornerShape(50),
colors = ButtonDefaults.buttonColors(
containerColor = palette.buttonPrimary,
contentColor = Color.White,
),
modifier = Modifier.padding(top = 24.dp),
) {
Text(
text = stringResource(R.string.kinh_dismiss),
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
}
}
}
@@ -0,0 +1,267 @@
package com.miti99.loto.ui.player
import android.view.HapticFeedbackConstants
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.miti99.loto.R
import com.miti99.loto.settings.Settings
import com.miti99.loto.state.PlayerBoardViewModel
import com.miti99.loto.ui.rememberReducedMotion
import com.miti99.loto.ui.toComposeColor
import com.miti99.loto.ui.theme.LotoTheme
/** The player-facing screen: action buttons, the card (or empty state), the chờ toast, and the Kinh celebration. */
@Composable
fun PlayerBoardScreen(
viewModel: PlayerBoardViewModel,
settings: Settings,
modifier: Modifier = Modifier,
) {
val state by viewModel.uiState.collectAsState()
val palette = LotoTheme.palette
val view = LocalView.current
val reducedMotion = rememberReducedMotion()
var confirmGenerate by remember { mutableStateOf(false) }
var confirmClear by remember { mutableStateOf(false) }
Column(modifier = modifier.fillMaxWidth()) {
// Action buttons
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),
modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp),
) {
Button(
onClick = {
if (state.grid != null) confirmGenerate = true else viewModel.generate()
},
shape = RoundedCornerShape(50),
colors = ButtonDefaults.buttonColors(
containerColor = palette.buttonPrimary,
contentColor = Color.White,
),
) {
Text(
text = stringResource(R.string.player_new_card),
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
if (state.grid != null) {
Button(
onClick = {
if (state.crossed.flatten().any { it }) confirmClear = true
else viewModel.clearMarks()
},
shape = RoundedCornerShape(50),
colors = ButtonDefaults.buttonColors(
containerColor = palette.buttonSecondaryBg,
contentColor = palette.buttonSecondaryText,
),
border = androidx.compose.foundation.BorderStroke(2.dp, palette.buttonSecondaryBorder),
) {
Text(
text = stringResource(R.string.player_clear_marks),
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
)
}
}
}
val grid = state.grid
if (grid != null) {
Box {
PlayerCardGrid(
grid = grid,
crossed = state.crossed,
rowComplete = state.rowComplete,
waitingCells = state.waitingCells,
sectionWaiting = state.sectionWaiting,
emptyCellColor = settings.emptyCellColor.toComposeColor(),
textScale = settings.boardTextScale,
reducedMotion = reducedMotion,
onCellClick = { row, col ->
if (!reducedMotion) {
view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
}
viewModel.onCellClick(row, col)
},
)
// Chờ toast — centered overlay chip, tap to dismiss.
state.toast?.let { message ->
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.matchParentSize(),
) {
Text(
text = message,
color = Color.White,
fontSize = 26.sp,
fontWeight = FontWeight.Black,
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(palette.toastBg)
.clickable { viewModel.dismissToast() }
.padding(horizontal = 24.dp, vertical = 12.dp),
)
}
}
}
} else {
PlayerEmptyState()
}
}
if (state.showCongrats) {
KinhDialog(congratsRow = state.congratsRow, onDismiss = { viewModel.dismissCongrats() })
if (state.celebrationTier >= 2 && !reducedMotion) {
ConfettiOverlay()
}
}
if (confirmGenerate) {
ConfirmDialog(
message = stringResource(R.string.player_confirm_new_card),
onConfirm = {
confirmGenerate = false
viewModel.generate()
},
onDismiss = { confirmGenerate = false },
)
}
if (confirmClear) {
ConfirmDialog(
message = stringResource(R.string.player_confirm_clear_marks),
onConfirm = {
confirmClear = false
viewModel.clearMarks()
},
onDismiss = { confirmClear = false },
)
}
}
@Composable
fun ConfirmDialog(message: String, onConfirm: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
text = { Text(message) },
confirmButton = {
TextButton(onClick = onConfirm) { Text(stringResource(R.string.dialog_confirm)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
}
/** No-card-yet hint with a faded preview mini-grid, mirroring the web copy. */
@Composable
private fun PlayerEmptyState() {
val palette = LotoTheme.palette
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().padding(vertical = 40.dp),
) {
Column(
modifier = Modifier
.widthIn(max = 320.dp)
.alpha(0.3f)
.clip(RoundedCornerShape(6.dp))
.border(1.dp, palette.buttonSecondaryBorder, RoundedCornerShape(6.dp))
.padding(bottom = 24.dp),
) {
for (rowStart in 0 until 27 step 9) {
Row(modifier = Modifier.fillMaxWidth()) {
for (i in rowStart until rowStart + 9) {
val filled = i % 3 == 0
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.weight(1f)
.aspectRatio(1f)
.background(
if (filled) palette.cellBg
else Settings.DEFAULT_EMPTY_CELL_COLOR.toComposeColor(),
),
) {
if (filled) {
Text(
text = "${(i * 7) % 90 + 1}",
fontSize = 9.sp,
color = palette.cellText,
)
}
}
}
}
}
}
Text(
text = buildAnnotatedString {
append(stringResource(R.string.player_empty_hint_prefix))
append(" ")
withStyle(
SpanStyle(
color = palette.title,
fontWeight = FontWeight.SemiBold,
fontStyle = FontStyle.Normal,
),
) {
append("\"" + stringResource(R.string.player_new_card) + "\"")
}
append(" ")
append(stringResource(R.string.player_empty_hint_suffix))
},
color = palette.subtitle,
fontSize = 16.sp,
fontStyle = FontStyle.Italic,
textAlign = TextAlign.Center,
)
Text(
text = stringResource(R.string.player_empty_wish),
color = palette.subtitle,
fontSize = 14.sp,
modifier = Modifier.padding(top = 6.dp),
)
}
}
@@ -0,0 +1,173 @@
package com.miti99.loto.ui.player
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import com.miti99.loto.R
import com.miti99.loto.game.PlayerCard
import com.miti99.loto.ui.theme.LotoTheme
/*
* Visual parity notes (from web PlayerBoard.svelte + app.css):
* - The sheet is 3 stacked 3×9 sections (Tân Tân physical card), each headed
* by an italic uppercase label band in the sky-blue section accent over a
* pale blue band, flanked by 2px rules; a bare band closes the sheet.
* - 4px vertical accent bars frame the sheet left and right.
* - Cells: white bg, slate half-alpha 1px borders, condensed bold digits;
* crossed = pale red bg + red diagonal slash; winning row = pale sky bg +
* sky slash; empty cells take the user's emptyCellColor (dimmed 15% in
* dark mode).
* - A waiting section's label band pulses an inset amber ring (2.4s); the
* awaited cell pulses a 3px amber ring (1.6s).
*/
private val sectionLabelIds =
listOf(R.string.section_label_1, R.string.section_label_2, R.string.section_label_3)
@Composable
fun PlayerCardGrid(
grid: List<List<Int>>,
crossed: List<List<Boolean>>,
rowComplete: List<Boolean>,
waitingCells: Set<PlayerCard.Cell>,
sectionWaiting: List<Boolean>,
emptyCellColor: Color,
textScale: Float,
reducedMotion: Boolean,
onCellClick: (row: Int, col: Int) -> Unit,
modifier: Modifier = Modifier,
) {
val palette = LotoTheme.palette
// IntrinsicSize.Min lets the framing accent bars match the sheet height.
Row(modifier = modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
VerticalAccentBar()
Column(modifier = Modifier.weight(1f)) {
for ((sectionIdx, startRow) in listOf(0, 3, 6).withIndex()) {
SectionLabel(
text = androidx.compose.ui.res.stringResource(sectionLabelIds[sectionIdx]),
waiting = sectionWaiting.getOrNull(sectionIdx) == true,
reducedMotion = reducedMotion,
)
for (row in startRow until startRow + 3) {
Row(modifier = Modifier.fillMaxWidth()) {
for (col in 0 until 9) {
PlayerCell(
num = grid[row][col],
isCrossed = crossed.getOrNull(row)?.getOrNull(col) == true,
rowComplete = rowComplete.getOrNull(row) == true,
isWaiting = waitingCells.contains(PlayerCard.Cell(row, col)),
emptyCellColor = emptyCellColor,
textScale = textScale,
reducedMotion = reducedMotion,
onClick = { onCellClick(row, col) },
modifier = Modifier.weight(1f),
)
}
}
}
}
// Decorative bottom band matching the label bands.
Box(
modifier = Modifier
.fillMaxWidth()
.height(16.dp)
.background(palette.sectionBandBg),
)
}
VerticalAccentBar()
}
}
@Composable
private fun VerticalAccentBar() {
Box(
modifier = Modifier
.width(4.dp)
.fillMaxHeight()
.background(LotoTheme.palette.sectionAccent),
)
}
@Composable
private fun SectionLabel(text: String, waiting: Boolean, reducedMotion: Boolean) {
val palette = LotoTheme.palette
// Waiting ring: inset amber ring breathing at 2.4s; static when reduced.
val ringAlpha: Float = if (waiting && !reducedMotion) {
val transition = rememberInfiniteTransition(label = "section-waiting")
val alpha by transition.animateFloat(
initialValue = 0.45f,
targetValue = 0.85f,
animationSpec = infiniteRepeatable(
animation = tween(1200, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
),
label = "section-waiting-alpha",
)
alpha
} else if (waiting) {
0.6f
} else {
0f
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.background(palette.sectionBandBg)
.then(
if (waiting) {
Modifier.border(2.dp, palette.waitingRing.copy(alpha = ringAlpha))
} else {
Modifier
},
)
.padding(horizontal = 12.dp, vertical = 7.dp),
) {
LabelRule(Modifier.weight(1f))
Text(
text = text.uppercase(),
color = palette.sectionAccent,
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
fontStyle = FontStyle.Italic,
letterSpacing = 0.14.em,
maxLines = 1,
modifier = Modifier.padding(horizontal = 12.dp),
)
LabelRule(Modifier.weight(1f))
}
}
@Composable
private fun LabelRule(modifier: Modifier) {
Box(
modifier = modifier
.height(2.dp)
.background(LotoTheme.palette.sectionAccent.copy(alpha = 0.7f)),
)
}
@@ -0,0 +1,141 @@
package com.miti99.loto.ui.player
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import com.miti99.loto.ui.theme.CondensedNumberFont
import com.miti99.loto.ui.theme.LotoTheme
/** Cell aspect ratio — the web card uses 3:4 (taller than wide) on phones. */
private const val CELL_ASPECT = 3f / 4f
/**
* One cell of the 9×9 player card. Number cells toggle a diagonal cross
* (red while the row is in progress, sky blue once the row wins); the cell
* holding a waiting row's awaited number pulses an amber inset ring.
*/
@Composable
fun PlayerCell(
num: Int,
isCrossed: Boolean,
rowComplete: Boolean,
isWaiting: Boolean,
emptyCellColor: Color,
textScale: Float,
reducedMotion: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val palette = LotoTheme.palette
if (num <= 0) {
Box(
modifier = modifier
.aspectRatio(CELL_ASPECT)
.background(emptyCellColor)
.background(palette.emptyCellDim)
.border(1.dp, palette.cellBorder),
)
return
}
val background = when {
isCrossed && rowComplete -> palette.cellWinBg
isCrossed -> palette.cellCrossedBg
else -> palette.cellBg
}
val textColor = when {
isCrossed && rowComplete -> palette.cellWinText
isCrossed -> palette.cellCrossedText
else -> palette.cellText
}
val crossColor = if (rowComplete) palette.crossWinStroke else palette.crossStroke
// Waiting pulse — 1.6s breathing ring, static at 0.7 alpha under
// reduced motion (mirrors the web's cell-waiting keyframes).
val ringAlpha: Float = if (isWaiting && !reducedMotion) {
val transition = rememberInfiniteTransition(label = "cell-waiting")
val alpha by transition.animateFloat(
initialValue = 0.45f,
targetValue = 0.95f,
animationSpec = infiniteRepeatable(
animation = tween(800, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
),
label = "cell-waiting-alpha",
)
alpha
} else if (isWaiting) {
0.7f
} else {
0f
}
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.aspectRatio(CELL_ASPECT)
.background(background)
.border(1.dp, palette.cellBorder)
.drawBehind {
if (isWaiting) {
drawRect(
color = palette.waitingRing.copy(alpha = ringAlpha),
style = androidx.compose.ui.graphics.drawscope.Stroke(
width = 3.dp.toPx() * 2, // inset stroke: half clips outside
),
)
}
if (isCrossed) {
val inset = 6.dp.toPx()
drawLine(
color = crossColor,
start = Offset(inset, inset),
end = Offset(size.width - inset, size.height - inset),
strokeWidth = 3.dp.toPx() / 2,
cap = StrokeCap.Round,
)
}
}
.clickable(onClick = onClick)
.semantics {
stateDescription = buildString {
append("Số $num")
if (isCrossed) append(", đã đánh dấu")
if (isWaiting) append(", đang chờ")
}
},
) {
Text(
text = num.toString(),
color = textColor,
fontFamily = CondensedNumberFont,
fontSize = (20 * textScale).sp,
letterSpacing = (-0.04).em,
textAlign = TextAlign.Center,
maxLines = 1,
)
}
}
@@ -0,0 +1,187 @@
package com.miti99.loto.ui.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import com.miti99.loto.R
import com.miti99.loto.ui.theme.LotoTheme
import com.miti99.loto.ui.toComposeColor
/** Office "Standard Colors" palette (10 swatches), same as the web. Default is Purple. */
private val PRESETS = listOf(
"#C00000", "#FF0000", "#FFC000", "#FFFF00", "#92D050",
"#00B050", "#00B0F0", "#0070C0", "#002060", "#7030A0",
)
/**
* Empty-cell color control: RGB sliders as the custom picker (the native
* stand-in for the web's `<input type=color>`) plus the Office preset
* swatches the web ships.
*/
@Composable
fun EmptyCellColorPicker(
value: String,
onPick: (String) -> Unit,
) {
val palette = LotoTheme.palette
// The sliders preview through local state and commit once on drag end —
// per-frame commits would rewrite the preferences file ~250× per drag.
var draft by remember { mutableStateOf<String?>(null) }
val shown = draft ?: value
val color = shown.toComposeColor()
val r = (color.red * 255).toInt()
val g = (color.green * 255).toInt()
val b = (color.blue * 255).toInt()
val commitDraft: () -> Unit = {
draft?.let(onPick)
draft = null
}
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.border(1.dp, palette.buttonSecondaryBorder, RoundedCornerShape(12.dp))
.padding(12.dp),
) {
SectionCaption(stringResource(R.string.settings_empty_color_custom))
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(48.dp, 40.dp)
.clip(RoundedCornerShape(8.dp))
.background(color)
.border(1.dp, palette.buttonSecondaryBorder, RoundedCornerShape(8.dp)),
)
Text(
text = shown.uppercase(),
color = palette.buttonSecondaryText,
fontSize = 14.sp,
fontFamily = FontFamily.Monospace,
modifier = Modifier.padding(start = 12.dp),
)
}
ChannelSlider("R", r, onChange = { draft = hex(it, g, b) }, onChangeFinished = commitDraft)
ChannelSlider("G", g, onChange = { draft = hex(r, it, b) }, onChangeFinished = commitDraft)
ChannelSlider("B", b, onChange = { draft = hex(r, g, it) }, onChangeFinished = commitDraft)
SectionCaption(
stringResource(R.string.settings_empty_color_presets),
modifier = Modifier.padding(top = 12.dp),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
for (hexValue in PRESETS.take(5)) {
PresetSwatch(hexValue, value, onPick, Modifier.weight(1f))
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(top = 8.dp),
) {
for (hexValue in PRESETS.drop(5)) {
PresetSwatch(hexValue, value, onPick, Modifier.weight(1f))
}
}
}
}
@Composable
private fun SectionCaption(text: String, modifier: Modifier = Modifier) {
Text(
text = text.uppercase(),
color = LotoTheme.palette.subtitle,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 0.08.em,
modifier = modifier.padding(bottom = 6.dp),
)
}
@Composable
private fun ChannelSlider(
label: String,
channel: Int,
onChange: (Int) -> Unit,
onChangeFinished: () -> Unit,
) {
val palette = LotoTheme.palette
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = label,
color = palette.subtitle,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(end = 8.dp),
)
Slider(
value = channel.toFloat(),
onValueChange = { onChange(it.toInt().coerceIn(0, 255)) },
onValueChangeFinished = onChangeFinished,
valueRange = 0f..255f,
colors = SliderDefaults.colors(
thumbColor = palette.title,
activeTrackColor = palette.title,
),
modifier = Modifier.weight(1f),
)
Text(
text = channel.toString(),
color = palette.subtitle,
fontSize = 12.sp,
modifier = Modifier.padding(start = 8.dp),
)
}
}
@Composable
private fun PresetSwatch(
hexValue: String,
selectedValue: String,
onPick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val palette = LotoTheme.palette
val selected = selectedValue.equals(hexValue, ignoreCase = true)
Box(
modifier = modifier
.aspectRatio(1f)
.clip(RoundedCornerShape(8.dp))
.background(hexValue.toComposeColor())
.border(
width = if (selected) 3.dp else 2.dp,
color = if (selected) palette.title else palette.buttonSecondaryBorder,
shape = RoundedCornerShape(8.dp),
)
.clickable { onPick(hexValue) },
)
}
private fun hex(r: Int, g: Int, b: Int): String =
"#%02X%02X%02X".format(r, g, b)
@@ -0,0 +1,103 @@
package com.miti99.loto.ui.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.miti99.loto.ui.theme.LotoTheme
/**
* Shared controls of the settings sheet, styled after the web's option
* buttons and full-row switches (rose selected state, slate borders).
*/
@Composable
fun ChoiceButton(
label: String,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val palette = LotoTheme.palette
val border = if (selected) palette.title else palette.buttonSecondaryBorder
val text = if (selected) palette.title else palette.buttonSecondaryText
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.border(2.dp, border, RoundedCornerShape(8.dp))
.background(if (selected) palette.title.copy(alpha = 0.08f) else Color.Transparent)
.clickable(onClick = onClick)
.padding(horizontal = 8.dp, vertical = 10.dp),
) {
Text(
text = label,
color = text,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
maxLines = 1,
)
}
}
/** Whole row toggles — tapping the label flips the switch too (web parity). */
@Composable
fun SwitchRow(
label: String,
checked: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier,
) {
val palette = LotoTheme.palette
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.border(2.dp, palette.buttonSecondaryBorder, RoundedCornerShape(8.dp))
.clickable(onClick = onToggle)
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Text(
text = label,
color = palette.buttonSecondaryText,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
)
// Compact pill switch matching the web's 40×24 track.
Box(
contentAlignment = if (checked) Alignment.CenterEnd else Alignment.CenterStart,
modifier = Modifier
.size(width = 40.dp, height = 24.dp)
.clip(RoundedCornerShape(50))
.background(if (checked) LotoTheme.palette.crossWinStroke else palette.buttonSecondaryBorder),
) {
Box(
modifier = Modifier
.padding(2.dp)
.size(20.dp)
.clip(CircleShape)
.background(Color.White),
)
}
}
}
@@ -0,0 +1,310 @@
package com.miti99.loto.ui.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.miti99.loto.R
import com.miti99.loto.settings.AppMode
import com.miti99.loto.settings.Settings
import com.miti99.loto.settings.ThemeSetting
import com.miti99.loto.state.SettingsViewModel
import com.miti99.loto.ui.player.ConfirmDialog
import com.miti99.loto.ui.theme.LotoTheme
/**
* The settings sheet, ported control-for-control from the web's
* `SettingsButton.svelte` dialog: theme, board text size, display mode,
* auto-call, sound/voice, empty-cell color, reset/done. Every change
* applies immediately — no save button.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsSheet(
viewModel: SettingsViewModel,
onDismiss: () -> Unit,
) {
val palette = LotoTheme.palette
val settings by viewModel.settings.collectAsState()
var confirmReset by remember { mutableStateOf(false) }
ModalBottomSheet(
onDismissRequest = onDismiss,
containerColor = palette.dialogBg,
// The sheet is tall (7 sections); open fully expanded like the web's
// full-height settings modal instead of a half-open peek.
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
) {
Text(
text = stringResource(R.string.settings_title),
color = palette.foreground,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
)
Hint(stringResource(R.string.settings_subtitle))
// Theme
SectionTitle(stringResource(R.string.settings_theme))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
for ((theme, labelId) in listOf(
ThemeSetting.AUTO to R.string.settings_theme_auto,
ThemeSetting.LIGHT to R.string.settings_theme_light,
ThemeSetting.DARK to R.string.settings_theme_dark,
)) {
ChoiceButton(
label = stringResource(labelId),
selected = settings.theme == theme,
onClick = { viewModel.setTheme(theme) },
modifier = Modifier.weight(1f),
)
}
}
// Board text size
SectionTitle(stringResource(R.string.settings_text_scale))
Hint(stringResource(R.string.settings_text_scale_hint))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
val labels = listOf(
R.string.settings_text_scale_s,
R.string.settings_text_scale_m,
R.string.settings_text_scale_l,
R.string.settings_text_scale_xl,
)
Settings.BOARD_TEXT_SCALES.forEachIndexed { i, scale ->
ChoiceButton(
label = stringResource(labels[i]),
selected = settings.boardTextScale == scale,
onClick = { viewModel.setBoardTextScale(scale) },
modifier = Modifier.weight(1f),
)
}
}
// Display mode
SectionTitle(stringResource(R.string.settings_mode))
Hint(stringResource(R.string.settings_mode_hint))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
for ((mode, labelId) in listOf(
AppMode.PLAYER to R.string.settings_mode_player,
AppMode.MASTER to R.string.settings_mode_master,
AppMode.BOTH to R.string.settings_mode_both,
)) {
ChoiceButton(
label = stringResource(labelId),
selected = settings.mode == mode,
onClick = { viewModel.setMode(mode) },
modifier = Modifier.weight(1f),
)
}
}
Text(
text = stringResource(
when (settings.mode) {
AppMode.PLAYER -> R.string.settings_mode_hint_player
AppMode.MASTER -> R.string.settings_mode_hint_master
AppMode.BOTH -> R.string.settings_mode_hint_both
},
),
color = palette.subtitle,
fontSize = 12.sp,
fontStyle = FontStyle.Italic,
modifier = Modifier.padding(top = 8.dp),
)
// Auto-call (only relevant when the master panel is visible)
if (settings.mode != AppMode.PLAYER) {
SectionTitle(stringResource(R.string.settings_auto_call))
Hint(stringResource(R.string.settings_auto_call_hint))
SwitchRow(
label = stringResource(R.string.settings_auto_call_switch),
checked = settings.autoCallEnabled,
onToggle = { viewModel.setAutoCallEnabled(!settings.autoCallEnabled) },
)
if (settings.autoCallEnabled) {
Column(modifier = Modifier.padding(start = 12.dp, top = 8.dp)) {
Text(
text = stringResource(
R.string.settings_auto_call_speed,
settings.autoCallSpeed,
),
color = palette.buttonSecondaryText,
fontSize = 14.sp,
)
Slider(
value = settings.autoCallSpeed.toFloat(),
onValueChange = {
viewModel.setAutoCallSpeed(it.toInt().coerceIn(1, 10))
},
valueRange = 1f..10f,
steps = 8,
colors = SliderDefaults.colors(
thumbColor = palette.title,
activeTrackColor = palette.title,
),
)
}
}
}
// Sound / voice
SectionTitle(stringResource(R.string.settings_sound))
Hint(stringResource(R.string.settings_sound_hint))
if (settings.mode != AppMode.PLAYER) {
SwitchRow(
label = stringResource(R.string.settings_voice_master),
checked = settings.voiceEnabledMaster,
onToggle = { viewModel.setVoiceEnabledMaster(!settings.voiceEnabledMaster) },
)
Hint(
stringResource(
if (settings.mode == AppMode.BOTH) R.string.settings_voice_master_hint_both
else R.string.settings_voice_master_hint,
),
small = true,
)
}
if (settings.mode != AppMode.MASTER) {
SwitchRow(
label = stringResource(R.string.settings_voice_player),
checked = settings.voiceEnabledPlayer,
onToggle = { viewModel.setVoiceEnabledPlayer(!settings.voiceEnabledPlayer) },
modifier = Modifier.padding(top = 8.dp),
)
if (settings.voiceEnabledPlayer && settings.mode != AppMode.BOTH) {
Column(modifier = Modifier.padding(start = 12.dp, top = 8.dp)) {
SwitchRow(
label = stringResource(R.string.settings_voice_waiting),
checked = settings.voiceWaitingNumber,
onToggle = {
viewModel.setVoiceWaitingNumber(!settings.voiceWaitingNumber)
},
)
Hint(stringResource(R.string.settings_voice_waiting_hint), small = true)
}
}
}
if (viewModel.voices.size > 1) {
Text(
text = stringResource(R.string.settings_voice_picker),
color = palette.buttonSecondaryText,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(top = 12.dp, bottom = 6.dp),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
for (voice in viewModel.voices) {
ChoiceButton(
label = voice.label,
selected = settings.voice == voice.id,
onClick = { viewModel.setVoice(voice.id) },
modifier = Modifier.weight(1f),
)
}
}
}
// Empty-cell color
SectionTitle(stringResource(R.string.settings_empty_color))
EmptyCellColorPicker(
value = settings.emptyCellColor,
onPick = { viewModel.setEmptyCellColor(it) },
)
// Footer actions
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth().padding(vertical = 20.dp),
) {
OutlinedButton(
onClick = { confirmReset = true },
shape = RoundedCornerShape(50),
) {
Text(
text = stringResource(R.string.settings_reset),
color = palette.buttonSecondaryText,
fontSize = 14.sp,
)
}
Button(
onClick = onDismiss,
shape = RoundedCornerShape(50),
colors = ButtonDefaults.buttonColors(
containerColor = palette.buttonPrimary,
contentColor = Color.White,
),
) {
Text(
text = stringResource(R.string.settings_done),
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
modifier = Modifier.padding(horizontal = 8.dp),
)
}
}
}
}
if (confirmReset) {
ConfirmDialog(
message = stringResource(R.string.settings_reset_confirm),
onConfirm = {
confirmReset = false
viewModel.reset()
},
onDismiss = { confirmReset = false },
)
}
}
@Composable
private fun SectionTitle(text: String) {
Text(
text = text,
color = LotoTheme.palette.foreground,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 20.dp, bottom = 8.dp),
)
}
@Composable
private fun Hint(text: String, small: Boolean = false) {
Text(
text = text,
color = LotoTheme.palette.subtitle,
fontSize = if (small) 12.sp else 14.sp,
modifier = Modifier.padding(bottom = if (small) 0.dp else 8.dp, top = if (small) 6.dp else 0.dp),
)
}
@@ -0,0 +1,211 @@
package com.miti99.loto.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.DeviceFontFamilyName
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import com.miti99.loto.settings.ThemeSetting
/**
* Palette lifted from the web app (`web/src/app.css` + the Tailwind classes
* used in its components) so the native board reads identically. Light =
* warm cream festive; dark = near-black navy.
*/
data class LotoPalette(
val background: Color,
val foreground: Color,
/** Sky-blue brand accent for section bands and frames. */
val sectionAccent: Color,
val sectionBandBg: Color,
/** Page title + primary action (web rose-600 / rose-400). */
val title: Color,
val subtitle: Color,
val buttonPrimary: Color,
val buttonSecondaryBg: Color,
val buttonSecondaryText: Color,
val buttonSecondaryBorder: Color,
// Player card cells
val cellBorder: Color,
val cellBg: Color,
val cellText: Color,
val cellCrossedBg: Color,
val cellCrossedText: Color,
val cellWinBg: Color,
val cellWinText: Color,
val crossStroke: Color,
val crossWinStroke: Color,
val waitingRing: Color,
val toastBg: Color,
// Kinh dialog
val dialogBg: Color,
val dialogText: Color,
val kinhTitle: Color,
val kinhRow: Color,
// Master panel
val masterHeading: Color,
val masterNewGame: Color,
val masterDraw: Color,
val masterStop: Color,
val tokenBg: Color,
val tokenBorder: Color,
val tokenText: Color,
val tokenUncalledBorder: Color,
val tokenUncalledBg: Color,
val tokenUncalledText: Color,
val lastCalledRing: Color,
val boardBg: Color,
val boardCellBorder: Color,
val countdownTrack: Color,
val countdownArc: Color,
/** Dim overlay drawn over empty cells in dark mode (web: black/15). */
val emptyCellDim: Color,
)
private val Light = LotoPalette(
background = Color(0xFFFFFBEB),
foreground = Color(0xFF1E293B),
sectionAccent = Color(0xFF1565C0),
sectionBandBg = Color(0xC7E8F0FA),
title = Color(0xFFE11D48),
subtitle = Color(0xFF475569),
buttonPrimary = Color(0xFFE11D48),
buttonSecondaryBg = Color.White,
buttonSecondaryText = Color(0xFF334155),
buttonSecondaryBorder = Color(0xFFCBD5E1),
cellBorder = Color(0x8094A3B8),
cellBg = Color.White,
cellText = Color.Black,
cellCrossedBg = Color(0xFFFEF2F2),
cellCrossedText = Color(0xFFB91C1C),
cellWinBg = Color(0xFFE0F2FE),
cellWinText = Color(0xFF0369A1),
crossStroke = Color(0xFFEF4444),
crossWinStroke = Color(0xFF0284C7),
waitingRing = Color(0xFFF59E0B),
toastBg = Color(0xBFF59E0B),
dialogBg = Color.White,
dialogText = Color(0xFF334155),
kinhTitle = Color(0xFFD97706),
kinhRow = Color(0xFFE11D48),
masterHeading = Color(0xFFB45309),
masterNewGame = Color(0xFFD97706),
masterDraw = Color(0xFF0369A1),
masterStop = Color(0xFFE11D48),
tokenBg = Color(0xFFFFFBEB),
tokenBorder = Color(0xFF0284C7),
tokenText = Color(0xFF0369A1),
tokenUncalledBorder = Color(0xFFCBD5E1),
tokenUncalledBg = Color(0x66F8FAFC),
tokenUncalledText = Color(0xFF94A3B8),
lastCalledRing = Color(0xFFEF4444),
boardBg = Color.White,
boardCellBorder = Color(0xCCE2E8F0),
countdownTrack = Color(0xFFE2E8F0),
countdownArc = Color(0xFFF59E0B),
emptyCellDim = Color.Transparent,
)
private val Dark = LotoPalette(
background = Color(0xFF050813),
foreground = Color(0xFFE2E8F0),
sectionAccent = Color(0xFF64B5F6),
sectionBandBg = Color(0x660D47A1),
title = Color(0xFFFB7185),
subtitle = Color(0xFFCBD5E1),
buttonPrimary = Color(0xFFE11D48),
buttonSecondaryBg = Color(0xFF1E293B),
buttonSecondaryText = Color(0xFFE2E8F0),
buttonSecondaryBorder = Color(0xFF475569),
cellBorder = Color(0x66475569),
cellBg = Color(0xFF1E293B),
cellText = Color(0xFFF1F5F9),
cellCrossedBg = Color(0x66450A0A),
cellCrossedText = Color(0xFFFCA5A5),
cellWinBg = Color(0x990C4A6E),
cellWinText = Color(0xFFBAE6FD),
crossStroke = Color(0xFFEF4444),
crossWinStroke = Color(0xFF0284C7),
waitingRing = Color(0xFFF59E0B),
toastBg = Color(0xBFD97706),
dialogBg = Color(0xFF1E293B),
dialogText = Color(0xFFE2E8F0),
kinhTitle = Color(0xFFFBBF24),
kinhRow = Color(0xFFFB7185),
masterHeading = Color(0xFFFBBF24),
masterNewGame = Color(0xFFD97706),
masterDraw = Color(0xFF0369A1),
masterStop = Color(0xFFE11D48),
tokenBg = Color(0xFFFEF3C7),
tokenBorder = Color(0xFF38BDF8),
tokenText = Color(0xFF0369A1),
tokenUncalledBorder = Color(0xFF475569),
tokenUncalledBg = Color(0x4D334155),
tokenUncalledText = Color(0xFF64748B),
lastCalledRing = Color(0xFFF87171),
boardBg = Color(0xFF1E293B),
boardCellBorder = Color(0x99334155),
countdownTrack = Color(0xFF334155),
countdownArc = Color(0xFFFBBF24),
emptyCellDim = Color(0x26000000),
)
val LocalLotoPalette = staticCompositionLocalOf { Light }
/**
* Condensed heavy digits — the Tân Tân card look (web `.tan-tan-num`, which
* self-hosts Roboto Condensed Bold; Android ships it as the system
* sans-serif-condensed family).
*/
val CondensedNumberFont = FontFamily(
Font(DeviceFontFamilyName("sans-serif-condensed"), weight = FontWeight.Bold),
)
object LotoTheme {
val palette: LotoPalette
@Composable @ReadOnlyComposable get() = LocalLotoPalette.current
}
@Composable
fun LotoTheme(
theme: ThemeSetting,
content: @Composable () -> Unit,
) {
val dark = when (theme) {
ThemeSetting.AUTO -> isSystemInDarkTheme()
ThemeSetting.LIGHT -> false
ThemeSetting.DARK -> true
}
val palette = if (dark) Dark else Light
val colorScheme = if (dark) {
darkColorScheme(
primary = palette.buttonPrimary,
background = palette.background,
surface = palette.dialogBg,
onBackground = palette.foreground,
onSurface = palette.foreground,
)
} else {
lightColorScheme(
primary = palette.buttonPrimary,
background = palette.background,
surface = palette.dialogBg,
onBackground = palette.foreground,
onSurface = palette.foreground,
)
}
CompositionLocalProvider(LocalLotoPalette provides palette) {
MaterialTheme(colorScheme = colorScheme, content = content)
}
}
/** Weight used by all board digits. */
val BoardDigitWeight = FontWeight.Bold
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Compat splash for API < 31. API 31+ uses windowSplashScreenBackground /
windowSplashScreenAnimatedIcon from AppTheme.NoActionBarLaunch instead.
Night variant lives in drawable-night/.
-->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splash_background" />
<item android:gravity="center">
<bitmap
android:src="@mipmap/ic_launcher_foreground"
android:gravity="center" />
</item>
</layer-list>
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Adaptive-icon background: the brand's rose → amber → rose diagonal, matching
the gradient in web/static/icons/source.svg. Replaces the Android Studio
template's teal grid.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M0,0h108v108h-108z">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="0"
android:startY="0"
android:endX="108"
android:endY="108">
<item android:offset="0.0" android:color="#FFF43F5E" />
<item android:offset="0.5" android:color="#FFF59E0B" />
<item android:offset="1.0" android:color="#FFF43F5E" />
</gradient>
</aapt:attr>
</path>
</vector>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Compat splash for API < 31. API 31+ uses windowSplashScreenBackground /
windowSplashScreenAnimatedIcon from AppTheme.NoActionBarLaunch instead.
Night variant lives in drawable-night/.
-->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splash_background" />
<item android:gravity="center">
<bitmap
android:src="@mipmap/ic_launcher_foreground"
android:gravity="center" />
</item>
</layer-list>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<!-- Android 13+ themed icons: without this layer the launcher falls back
to the full-colour icon on themed home screens. -->
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Same layers as ic_launcher: on API 26+ the launcher applies its own mask, so
the round variant must not bake in a circle of its own. The circular
ic_launcher_round.png files remain for API 25 and below.
-->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Matches the dark background custom property in web/src/app.css. The
app ships a full dark theme; a light splash flashed white before the
WebView painted. -->
<color name="splash_background">#050813</color>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Dark variant of the shell theme; splash_background resolves to the
near-black navy from values-night/colors.xml. -->
<style name="Theme.Loto" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/splash_background</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Matches the light background custom property in web/src/app.css. -->
<color name="splash_background">#FFFBEB</color>
</resources>
+104
View File
@@ -0,0 +1,104 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Lô tô</string>
<string name="app_subtitle">🏮 Hội chợ TN1</string>
<!-- Player board -->
<string name="player_new_card">Tạo bảng mới</string>
<string name="player_clear_marks">Xoá đánh dấu</string>
<string name="player_confirm_new_card">Bạn có muốn tạo lại bảng không?</string>
<string name="player_confirm_clear_marks">Bạn có muốn xoá tất cả đánh dấu không?</string>
<string name="player_empty_hint_prefix">Nhấn</string>
<string name="player_empty_hint_suffix">để bắt đầu chơi</string>
<string name="player_empty_wish">🎫 Chúc cả nhà một ván vui vẻ</string>
<string name="player_board_label">Bảng lô tô</string>
<string name="toast_waiting">Chờ %1$d</string>
<string name="toast_dismiss">Đóng thông báo</string>
<!-- Section bands (Tân Tân physical card) -->
<string name="section_label_1">Lô tô</string>
<string name="section_label_2">TN1 (2014–2017)</string>
<string name="section_label_3">Độc-Đỉnh-Điên</string>
<!-- Kinh celebration -->
<string name="kinh_title">Kinh!</string>
<string name="kinh_row_prefix">Hàng</string>
<string name="kinh_row_suffix">đã đầy đủ!</string>
<string name="kinh_shout">Hãy hô to "Kinh!" 🎶</string>
<string name="kinh_dismiss">Tuyệt vời! 🥳</string>
<string name="kinh_close">Đóng</string>
<!-- Master panel -->
<string name="master_heading">Quản trò</string>
<string name="master_new_game">Ván mới</string>
<string name="master_confirm_new_game">Bạn có muốn tạo ván mới không?</string>
<string name="master_draw">Xổ số</string>
<string name="master_auto_start">Bắt đầu</string>
<string name="master_auto_stop">Dừng</string>
<string name="master_auto_speed">Tự động: %1$ds/số</string>
<string name="master_last_called">Số vừa xổ</string>
<string name="master_called_count">Đã xổ: %1$d/90 · Còn lại: %2$d</string>
<string name="master_call_order">Thứ tự đã xổ:</string>
<string name="master_board_label">Bảng theo dõi số đã xổ</string>
<string name="master_countdown">Đếm ngược: %1$d giây</string>
<string name="master_empty_badge">Chế độ Quản trò</string>
<string name="master_empty_hint_prefix">Nhấn</string>
<string name="master_empty_hint_suffix">để bắt đầu xổ số</string>
<string name="master_empty_ready">🎤 Đã sẵn sàng</string>
<!-- Footer -->
<string name="footer_inspiration">Lấy cảm hứng từ những buổi họp lớp thiếu bộ lô tô của TN1 (2014–2017)</string>
<string name="footer_credit">Made by miti99 with ❤️</string>
<!-- Shared dialogs -->
<string name="dialog_confirm">Đồng ý</string>
<string name="dialog_cancel">Hủy</string>
<!-- Settings sheet -->
<string name="settings_open">Cài đặt</string>
<string name="settings_title">Cài đặt</string>
<string name="settings_subtitle">Tuỳ chỉnh giao diện bảng lô tô</string>
<string name="settings_theme">Giao diện</string>
<string name="settings_theme_auto">Tự động</string>
<string name="settings_theme_light">Sáng</string>
<string name="settings_theme_dark">Tối</string>
<string name="settings_text_scale">Cỡ chữ bảng</string>
<string name="settings_text_scale_hint">Cỡ số trên bảng người chơi và bảng quản trò</string>
<string name="settings_text_scale_s">Nhỏ</string>
<string name="settings_text_scale_m">Vừa</string>
<string name="settings_text_scale_l">Lớn</string>
<string name="settings_text_scale_xl">Rất lớn</string>
<string name="settings_mode">Chế độ hiển thị</string>
<string name="settings_mode_hint">Chọn vai trò để chỉ hiện phần liên quan</string>
<string name="settings_mode_player">Người chơi</string>
<string name="settings_mode_master">Quản trò</string>
<string name="settings_mode_both">Cả hai</string>
<string name="settings_mode_hint_player">Chỉ bảng người chơi</string>
<string name="settings_mode_hint_master">Chỉ bảng quản trò (xổ số, theo dõi)</string>
<string name="settings_mode_hint_both">Cả hai bảng — quản trò tự đánh dấu cho người chơi</string>
<string name="settings_auto_call">Tự động xổ</string>
<string name="settings_auto_call_hint">Tự xổ số liên tiếp khi đang ở chế độ quản trò</string>
<string name="settings_auto_call_switch">Tự xổ số</string>
<string name="settings_auto_call_speed">Tốc độ: %1$d giây/số</string>
<string name="settings_sound">Âm thanh</string>
<string name="settings_sound_hint">Đọc số bằng tiếng Việt</string>
<string name="settings_voice_master">Quản trò đọc số</string>
<string name="settings_voice_master_hint_both">Đọc số đã xổ + báo Chờ/Kinh.</string>
<string name="settings_voice_master_hint">Đọc số đã xổ.</string>
<string name="settings_voice_player">Báo Chờ / Kinh</string>
<string name="settings_voice_waiting">Đọc thêm số đang chờ</string>
<string name="settings_voice_waiting_hint">Tắt: chỉ nói "Chờ". Bật: nói "Chờ {số}".</string>
<string name="settings_voice_picker">Giọng đọc</string>
<string name="settings_empty_color">Màu ô trống</string>
<string name="settings_empty_color_custom">Tuỳ chỉnh</string>
<string name="settings_empty_color_presets">Mẫu sẵn</string>
<string name="settings_reset">Đặt lại</string>
<string name="settings_reset_confirm">Đặt lại tất cả cài đặt về mặc định?</string>
<string name="settings_done">Xong</string>
<!-- Exit confirmation (back press with nothing to close) -->
<string name="exit_title">Thoát Lô tô?</string>
<string name="exit_message">Ván đang chơi sẽ được lưu lại.</string>
<string name="exit_confirm">Thoát</string>
<string name="exit_cancel">Ở lại</string>
</resources>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Framework-level shell theme only; the real theming (colors, dark
mode) lives in Compose MaterialTheme. windowBackground matches the
web's page background so the pre-composition frame doesn't flash.
Night variant lives in values-night/. -->
<style name="Theme.Loto" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:windowBackground">@color/splash_background</item>
</style>
<!-- Launch theme: API 31+ system splash via core-splashscreen; the
android:background item is the compat splash for API < 31. -->
<style name="Theme.Loto.Splash" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/splash_background</item>
<item name="windowSplashScreenAnimatedIcon">@mipmap/ic_launcher_foreground</item>
<item name="postSplashScreenTheme">@style/Theme.Loto</item>
<item name="android:background">@drawable/splash</item>
</style>
</resources>
@@ -0,0 +1,34 @@
package com.miti99.loto
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* In-memory [DataStore] for JVM unit tests. File-backed DataStore cannot be
* exercised on a Windows JVM (its atomic rename fails when the target file
* exists — java.io.File.renameTo semantics), and repositories only need the
* [DataStore] contract anyway: "process death" is simulated by pointing a
* fresh repository at the same store instance.
*/
class InMemoryDataStore(
initial: Preferences = emptyPreferences(),
) : DataStore<Preferences> {
private val state = MutableStateFlow(initial)
private val mutex = Mutex()
override val data: Flow<Preferences> = state
override suspend fun updateData(
transform: suspend (t: Preferences) -> Preferences,
): Preferences = mutex.withLock {
val next = transform(state.value)
state.value = next
next
}
}
@@ -0,0 +1,32 @@
package com.miti99.loto.audio
/**
* Recording fake for [VoicePlayerApi]. [utterances] logs every speak call;
* [active] models the single in-flight utterance ([speak] replaces it —
* the cancellation contract — and [cancel] clears it).
*/
class FakeVoicePlayer : VoicePlayerApi {
override var voiceId: String = "hoai-my"
val utterances = mutableListOf<List<String>>()
var active: List<String>? = null
private set
var released = false
private set
override fun speak(clipNames: List<String>) {
utterances.add(clipNames)
active = clipNames
}
override fun cancel() {
active = null
}
override fun release() {
released = true
}
}
@@ -0,0 +1,62 @@
package com.miti99.loto.audio
import java.io.File
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class VoiceCatalogTest {
@Test
fun `parses the real bundled manifest`() {
// The same file Gradle mounts into assets/ — read straight from the
// web app's static dir (test working dir is the app module).
val manifest = File("../../web/static/audio/manifest.json")
assertTrue("expected ${manifest.absolutePath} to exist", manifest.exists())
val voices = VoiceCatalog.parse(manifest.readText())
assertEquals(listOf("hoai-my", "nam-minh"), voices.map { it.id })
assertEquals("vi-VN-HoaiMyNeural", voices[0].edgeName)
assertEquals("female", voices[0].gender)
assertEquals("Nam Minh (nam)", voices[1].label)
}
@Test
fun `falls back to the known voices on corrupt JSON`() {
assertEquals(VoiceCatalog.FALLBACK_VOICES, VoiceCatalog.parse("{not json"))
}
@Test
fun `falls back when the voices key is missing`() {
assertEquals(VoiceCatalog.FALLBACK_VOICES, VoiceCatalog.parse("""{"other": 1}"""))
}
@Test
fun `falls back when the voices array is empty`() {
assertEquals(VoiceCatalog.FALLBACK_VOICES, VoiceCatalog.parse("""{"voices": []}"""))
}
@Test
fun `falls back when an entry misses a field`() {
assertEquals(
VoiceCatalog.FALLBACK_VOICES,
VoiceCatalog.parse("""{"voices": [{"id": "x"}]}"""),
)
}
@Test
fun `default voice is the first manifest entry`() {
val voices = listOf(
Voice("nam-minh", "vi-VN-NamMinhNeural", "Nam Minh (nam)", "male"),
Voice("hoai-my", "vi-VN-HoaiMyNeural", "Hoai My (nữ)", "female"),
)
assertEquals("nam-minh", VoiceCatalog.defaultVoiceId(voices))
assertEquals("hoai-my", VoiceCatalog.defaultVoiceId(emptyList()))
}
@Test
fun `clipPath builds the asset-relative mp3 path`() {
assertEquals("audio/hoai-my/42.mp3", VoiceCatalog.clipPath("hoai-my", "42"))
assertEquals("audio/nam-minh/cho.mp3", VoiceCatalog.clipPath("nam-minh", VoiceCatalog.CLIP_CHO))
assertEquals("audio/hoai-my/kinh.mp3", VoiceCatalog.clipPath("hoai-my", VoiceCatalog.CLIP_KINH))
}
}
@@ -0,0 +1,59 @@
package com.miti99.loto.audio
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* Twins of `web/src/lib/voice.test.js` against the recording fake — the
* cancellation and chờ-sequencing contracts the production player must obey.
*/
class VoicePlayerTest {
@Test
fun `cancel clears the active clip`() {
val player = FakeVoicePlayer()
player.playNumber(7)
assertEquals(listOf("7"), player.active)
player.cancel()
assertNull(player.active)
}
@Test
fun `a second playNumber cancels the first`() {
val player = FakeVoicePlayer()
player.playNumber(3)
player.playNumber(5)
assertEquals(listOf(listOf("3"), listOf("5")), player.utterances)
// Only the second utterance is in flight.
assertEquals(listOf("5"), player.active)
}
@Test
fun `playWaiting plays only cho when voiceWaitingNumber is off`() {
val player = FakeVoicePlayer()
player.playWaiting(42, voiceWaitingNumber = false, modeIsBoth = false)
assertEquals(listOf(listOf("cho")), player.utterances)
}
@Test
fun `playWaiting chains cho then number when voiceWaitingNumber is on`() {
val player = FakeVoicePlayer()
player.playWaiting(42, voiceWaitingNumber = true, modeIsBoth = false)
assertEquals(listOf(listOf("cho", "42")), player.utterances)
}
@Test
fun `playWaiting suppresses trailing number in both mode even when flag is on`() {
val player = FakeVoicePlayer()
player.playWaiting(42, voiceWaitingNumber = true, modeIsBoth = true)
assertEquals(listOf(listOf("cho")), player.utterances)
}
@Test
fun `playBingo speaks the kinh clip`() {
val player = FakeVoicePlayer()
player.playBingo()
assertEquals(listOf(listOf("kinh")), player.utterances)
}
}
@@ -0,0 +1,145 @@
package com.miti99.loto.game
import kotlin.random.Random
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Ported one-to-one from `web/src/lib/game-logic.test.js` (generateGrid
* describe blocks). Test names mirror the web `it` strings.
*/
class CardGeneratorTest {
private val numRows = CardGenerator.NUM_ROWS
private val numCols = CardGenerator.NUM_COLS
private val numPerRow = CardGenerator.NUM_PER_ROW
/** Column N (0-indexed) holds numbers in this tens range. */
private val colRange = listOf(
1 to 9, 10 to 19, 20 to 29, 30 to 39, 40 to 49,
50 to 59, 60 to 69, 70 to 79, 80 to 90,
)
private fun rowSums(grid: List<List<Int>>) = grid.map { row -> row.count { it > 0 } }
private fun colSums(grid: List<List<Int>>) =
(0 until numCols).map { c -> grid.count { row -> row[c] > 0 } }
// -- shape invariants --
@Test
fun `returns a 9x9 matrix`() {
val g = CardGenerator.generateGrid()
assertEquals(numRows, g.size)
for (row in g) assertEquals(numCols, row.size)
}
@Test
fun `each row has exactly 5 non-zero numbers`() {
repeat(200) {
assertEquals(List(numRows) { numPerRow }, rowSums(CardGenerator.generateGrid()))
}
}
@Test
fun `each column has exactly 5 non-zero numbers`() {
repeat(200) {
assertEquals(List(numCols) { numPerRow }, colSums(CardGenerator.generateGrid()))
}
}
@Test
fun `never produces duplicates in a single card`() {
repeat(50) {
val flat = CardGenerator.generateGrid().flatten().filter { it > 0 }
assertEquals(flat.size, flat.toSet().size)
}
}
// -- column number ranges (lô tô hội chợ Tân Tân) --
@Test
fun `each non-zero cell sits in its column's tens range`() {
repeat(50) {
val g = CardGenerator.generateGrid()
for (r in 0 until numRows) {
for (c in 0 until numCols) {
val n = g[r][c]
if (n == 0) continue
val (lo, hi) = colRange[c]
assertTrue("row=$r col=$c num=$n", n in lo..hi)
}
}
}
}
@Test
fun `numbers within each column are sorted ascending top-to-bottom`() {
repeat(50) {
val g = CardGenerator.generateGrid()
for (c in 0 until numCols) {
val colNums = g.map { it[c] }.filter { it > 0 }
assertEquals(colNums.sorted(), colNums)
}
}
}
@Test
fun `no row has 3 consecutive filled columns (rejection-sampled soft constraint)`() {
repeat(300) { trial ->
val g = CardGenerator.generateGrid()
for (r in 0 until numRows) {
for (c in 0 until numCols - 2) {
assertTrue(
"trial=$trial row=$r cols $c,${c + 1},${c + 2}",
!(g[r][c] > 0 && g[r][c + 1] > 0 && g[r][c + 2] > 0),
)
}
}
}
}
@Test
fun `col 0 only holds numbers from 1-9 (5 per card)`() {
val g = CardGenerator.generateGrid()
val col0 = g.map { it[0] }.filter { it > 0 }
assertEquals(5, col0.size)
for (n in col0) assertTrue(n in 1..9)
}
@Test
fun `col 8 only holds numbers from 80-90 (5 per card)`() {
val g = CardGenerator.generateGrid()
val col8 = g.map { it[8] }.filter { it > 0 }
assertEquals(5, col8.size)
for (n in col8) assertTrue(n in 80..90)
}
// -- native-only property test with injected deterministic Random --
@Test
fun `1000 seeded cards all satisfy row, column and decade invariants`() {
repeat(1000) { seed ->
val g = CardGenerator.generateGrid(Random(seed))
assertEquals("seed=$seed rows", List(numRows) { numPerRow }, rowSums(g))
assertEquals("seed=$seed cols", List(numCols) { numPerRow }, colSums(g))
for (r in 0 until numRows) {
for (c in 0 until numCols) {
val n = g[r][c]
if (n == 0) continue
val (lo, hi) = colRange[c]
assertTrue("seed=$seed row=$r col=$c num=$n", n in lo..hi)
}
}
}
}
@Test
fun `same seed generates the same card`() {
assertEquals(
CardGenerator.generateGrid(Random(42)),
CardGenerator.generateGrid(Random(42)),
)
}
}
@@ -0,0 +1,83 @@
package com.miti99.loto.game
import kotlin.random.Random
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Ported one-to-one from `web/src/lib/master-store.test.js` (pure deck
* semantics; the storage-validation cases have their twins at the
* persistence layer). Test names mirror the web `it` strings.
*/
class DrawDeckTest {
@Test
fun `starts empty`() {
val deck = DrawDeck()
assertEquals(emptyList<Int>(), deck.called)
assertEquals(emptyList<Int>(), deck.remaining)
}
@Test
fun `startNewGame fills remaining with 90 unique 1 to 90`() {
val deck = DrawDeck()
deck.startNewGame()
assertEquals(emptyList<Int>(), deck.called)
assertEquals(90, deck.remaining.size)
val set = deck.remaining.toSet()
assertEquals(90, set.size)
for (n in 1..90) assertTrue(set.contains(n))
}
@Test
fun `drawNext appends called and shifts remaining, returns drawn`() {
val deck = DrawDeck()
deck.startNewGame()
val first = deck.remaining.first()
val drawn = deck.drawNext()
assertEquals(first, drawn)
assertEquals(listOf(first), deck.called)
assertEquals(89, deck.remaining.size)
assertFalse(deck.remaining.contains(first))
}
@Test
fun `drawNext returns null when exhausted`() {
val deck = DrawDeck()
deck.startNewGame()
repeat(90) { deck.drawNext() }
assertNull(deck.drawNext())
assertEquals(90, deck.called.size)
assertEquals(emptyList<Int>(), deck.remaining)
}
@Test
fun `restore round-trip preserves state`() {
val deck = DrawDeck()
deck.startNewGame()
deck.drawNext()
deck.drawNext()
val calledBefore = deck.called
val remainingBefore = deck.remaining
val restored = DrawDeck()
restored.restore(calledBefore, remainingBefore)
assertEquals(calledBefore, restored.called)
assertEquals(remainingBefore, restored.remaining)
}
@Test
fun `seeded deck draws deterministically and in monotonic call order`() {
val a = DrawDeck(Random(7))
val b = DrawDeck(Random(7))
a.startNewGame()
b.startNewGame()
val drawsA = (1..90).mapNotNull { a.drawNext() }
val drawsB = (1..90).mapNotNull { b.drawNext() }
assertEquals(drawsA, drawsB)
assertEquals(drawsA, a.called)
}
}
@@ -0,0 +1,166 @@
package com.miti99.loto.game
import com.miti99.loto.settings.AppMode
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Ported one-to-one from `web/src/lib/player-auto-cross.test.js`. Test names
* mirror the web `it` strings.
*/
class PlayerAutoCrossTest {
private fun gridWith(num: Int): List<List<Int>> =
List(9) { r -> List(9) { c -> if (r == 0 && c == 0) num else 0 } }
private fun emptyCrossed(): List<List<Boolean>> = List(9) { List(9) { false } }
@Test
fun `no-op when called is empty`() {
val r = PlayerAutoCross.applyMasterCalls(
grid = gridWith(7),
crossed = emptyCrossed(),
called = emptyList(),
lastHandledIndex = 0,
manualUnticks = emptySet(),
mode = AppMode.BOTH,
)
assertFalse(r.changed)
assertEquals(0, r.lastHandledIndex)
}
@Test
fun `no-op when cursor at length`() {
val r = PlayerAutoCross.applyMasterCalls(
grid = gridWith(7),
crossed = emptyCrossed(),
called = listOf(7),
lastHandledIndex = 1,
manualUnticks = emptySet(),
mode = AppMode.BOTH,
)
assertFalse(r.changed)
assertEquals(1, r.lastHandledIndex)
}
@Test
fun `mode=player advances cursor without flipping`() {
val r = PlayerAutoCross.applyMasterCalls(
grid = gridWith(7),
crossed = emptyCrossed(),
called = listOf(7, 8, 9),
lastHandledIndex = 0,
manualUnticks = emptySet(),
mode = AppMode.PLAYER,
)
assertFalse(r.changed)
assertEquals(3, r.lastHandledIndex)
assertFalse(r.crossed[0][0])
}
@Test
fun `mode=both crosses uncrossed cell on match`() {
val r = PlayerAutoCross.applyMasterCalls(
grid = gridWith(7),
crossed = emptyCrossed(),
called = listOf(7),
lastHandledIndex = 0,
manualUnticks = emptySet(),
mode = AppMode.BOTH,
)
assertTrue(r.changed)
assertEquals(1, r.lastHandledIndex)
assertTrue(r.crossed[0][0])
}
@Test
fun `mode=both replays full back-history when cursor=0`() {
val grid = List(9) { r ->
List(9) { c ->
when {
r == 0 && c == 0 -> 5
r == 1 && c == 1 -> 12
r == 2 && c == 2 -> 88
else -> 0
}
}
}
val r = PlayerAutoCross.applyMasterCalls(
grid = grid,
crossed = emptyCrossed(),
called = listOf(5, 12, 88, 89 /* off-board */),
lastHandledIndex = 0,
manualUnticks = emptySet(),
mode = AppMode.BOTH,
)
assertTrue(r.changed)
assertEquals(4, r.lastHandledIndex)
assertTrue(r.crossed[0][0])
assertTrue(r.crossed[1][1])
assertTrue(r.crossed[2][2])
}
@Test
fun `manualUnticks numbers are skipped`() {
val r = PlayerAutoCross.applyMasterCalls(
grid = gridWith(7),
crossed = emptyCrossed(),
called = listOf(7),
lastHandledIndex = 0,
manualUnticks = setOf(7),
mode = AppMode.BOTH,
)
assertFalse(r.changed)
assertEquals(1, r.lastHandledIndex)
assertFalse(r.crossed[0][0])
}
@Test
fun `grid=null no-ops in both mode (still advances cursor)`() {
val r = PlayerAutoCross.applyMasterCalls(
grid = null,
crossed = emptyList(),
called = listOf(7),
lastHandledIndex = 0,
manualUnticks = emptySet(),
mode = AppMode.BOTH,
)
assertFalse(r.changed)
assertEquals(1, r.lastHandledIndex)
}
@Test
fun `stale cursor beyond a shrunken history resets and replays from zero`() {
// A round reset conflated with the new round's first draws must not
// leave auto-cross dead (native hardening beyond the web spec).
val r = PlayerAutoCross.applyMasterCalls(
grid = gridWith(7),
crossed = emptyCrossed(),
called = listOf(7),
lastHandledIndex = 31,
manualUnticks = emptySet(),
mode = AppMode.BOTH,
)
assertTrue(r.changed)
assertEquals(1, r.lastHandledIndex)
assertTrue(r.crossed[0][0])
}
@Test
fun `returns same crossed reference when no flip happens`() {
val crossed = emptyCrossed()
val r = PlayerAutoCross.applyMasterCalls(
grid = gridWith(7),
crossed = crossed,
called = listOf(89 /* not on grid */),
lastHandledIndex = 0,
manualUnticks = emptySet(),
mode = AppMode.BOTH,
)
assertFalse(r.changed)
assertSame(crossed, r.crossed)
}
}
@@ -0,0 +1,116 @@
package com.miti99.loto.game
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Ported one-to-one from `web/src/lib/game-logic.test.js` (isRowComplete,
* getWaitingNumber and findUncrossedCell describe blocks). Test names mirror
* the web `it` strings.
*/
class PlayerCardTest {
// -- isRowComplete --
@Test
fun `returns true when every number in the row is crossed`() {
val grid = listOf(listOf(0, 1, 0, 2, 0, 3, 0, 4, 5))
val crossed = listOf(listOf(false, true, false, true, false, true, false, true, true))
assertTrue(PlayerCard.isRowComplete(grid, crossed, 0))
}
@Test
fun `returns false when at least one number is uncrossed`() {
val grid = listOf(listOf(0, 1, 0, 2, 0, 3, 0, 4, 5))
val crossed = listOf(listOf(false, true, false, false, false, true, false, true, true))
assertFalse(PlayerCard.isRowComplete(grid, crossed, 0))
}
@Test
fun `returns false for an all-zero row (no numbers, not a win)`() {
val grid = listOf(List(9) { 0 })
val crossed = listOf(List(9) { false })
assertFalse(PlayerCard.isRowComplete(grid, crossed, 0))
}
@Test
fun `ignores 0 cells when checking crossed state`() {
val grid = listOf(listOf(0, 7, 0, 0, 0, 0, 0, 0, 0))
// 7 is crossed; the zeros are not (and shouldn't matter)
val crossed = listOf(listOf(false, true, false, false, false, false, false, false, false))
assertTrue(PlayerCard.isRowComplete(grid, crossed, 0))
}
// -- getWaitingNumber --
@Test
fun `returns the single uncrossed number when exactly one remains`() {
val grid = listOf(listOf(0, 1, 0, 2, 0, 3, 0, 4, 5))
val crossed = listOf(listOf(false, true, false, false, false, true, false, true, true))
assertEquals(2, PlayerCard.getWaitingNumber(grid, crossed, 0))
}
@Test
fun `returns null when more than one number remains`() {
val grid = listOf(listOf(0, 1, 0, 2, 0, 3, 0, 4, 5))
val crossed = listOf(listOf(false, true, false, false, false, false, false, true, true))
assertNull(PlayerCard.getWaitingNumber(grid, crossed, 0))
}
@Test
fun `returns null when zero numbers remain (row complete)`() {
val grid = listOf(listOf(0, 1, 0, 2, 0, 3, 0, 4, 5))
val crossed = listOf(listOf(false, true, false, true, false, true, false, true, true))
assertNull(PlayerCard.getWaitingNumber(grid, crossed, 0))
}
@Test
fun `returns null for an empty row`() {
val grid = listOf(List(9) { 0 })
val crossed = listOf(List(9) { false })
assertNull(PlayerCard.getWaitingNumber(grid, crossed, 0))
}
// -- findUncrossedCell --
private val grid = listOf(
listOf(0, 11, 0, 33, 0, 55, 0, 77, 88),
listOf(1, 0, 22, 0, 44, 0, 66, 0, 89),
listOf(0, 12, 23, 0, 0, 56, 0, 78, 0),
)
private fun uncrossed() = grid.map { row -> row.map { false } }
@Test
fun `returns coords of the matching cell when uncrossed`() {
assertEquals(PlayerCard.Cell(0, 3), PlayerCard.findUncrossedCell(grid, uncrossed(), 33))
assertEquals(PlayerCard.Cell(1, 0), PlayerCard.findUncrossedCell(grid, uncrossed(), 1))
}
@Test
fun `returns null when the number isn't on the card`() {
assertNull(PlayerCard.findUncrossedCell(grid, uncrossed(), 99))
assertNull(PlayerCard.findUncrossedCell(grid, uncrossed(), 5))
}
@Test
fun `returns null when the cell is already crossed`() {
val crossed = uncrossed().map { it.toMutableList() }
crossed[0][3] = true // 33 already marked
assertNull(PlayerCard.findUncrossedCell(grid, crossed, 33))
}
@Test
fun `ignores empty (0) cells even when num=0`() {
assertNull(PlayerCard.findUncrossedCell(grid, uncrossed(), 0))
}
@Test
fun `scans rows top-down, columns left-right`() {
// Both row 0 col 1 and row 2 col 1 hold 11/12 — first match wins.
assertEquals(PlayerCard.Cell(0, 1), PlayerCard.findUncrossedCell(grid, uncrossed(), 11))
}
}
@@ -0,0 +1,189 @@
package com.miti99.loto.settings
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import com.miti99.loto.InMemoryDataStore
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Twins of the validation cases in `web/src/lib/settings-store.test.js`.
* Type-mismatch cases from the web (e.g. `autoCallSpeed: "5"`) have no
* counterpart: DataStore keys are typed, so a wrong-typed value is
* unrepresentable — range/allowlist violations are what remain.
*/
class SettingsRepositoryTest {
private val voiceIds = setOf("hoai-my", "nam-minh")
private fun repo(store: DataStore<Preferences>) =
SettingsRepository(store, voiceIds, defaultVoiceId = "hoai-my")
private fun runWithRepo(
block: suspend (SettingsRepository, DataStore<Preferences>) -> Unit,
) = runTest {
val store = InMemoryDataStore()
block(repo(store), store)
}
@Test
fun `defaults match the web DEFAULT_SETTINGS contract`() = runWithRepo { repository, _ ->
val s = repository.settingsFlow.first()
assertEquals("#7030A0", s.emptyCellColor)
assertEquals(ThemeSetting.AUTO, s.theme)
assertEquals(AppMode.PLAYER, s.mode)
assertEquals(false, s.autoCallEnabled)
assertEquals(5, s.autoCallSpeed)
assertEquals(true, s.voiceEnabledMaster)
assertEquals(false, s.voiceEnabledPlayer)
assertEquals(false, s.voiceWaitingNumber)
assertEquals("hoai-my", s.voice)
assertEquals(1f, s.boardTextScale)
}
@Test
fun `writes all fields and reads them back after recreating the repo`() = runTest {
// Same backing store, fresh repository instance = process restart.
val store = InMemoryDataStore()
val repo1 = repo(store)
repo1.setEmptyCellColor("#112233")
repo1.setTheme(ThemeSetting.DARK)
repo1.setMode(AppMode.BOTH)
repo1.setAutoCallEnabled(true)
repo1.setAutoCallSpeed(7)
repo1.setVoiceEnabledMaster(false)
repo1.setVoiceEnabledPlayer(true)
repo1.setVoiceWaitingNumber(true)
repo1.setVoice("nam-minh")
repo1.setBoardTextScale(1.15f)
val repo2 = repo(store)
val s = repo2.settingsFlow.first()
assertEquals("#112233", s.emptyCellColor)
assertEquals(ThemeSetting.DARK, s.theme)
assertEquals(AppMode.BOTH, s.mode)
assertEquals(true, s.autoCallEnabled)
assertEquals(7, s.autoCallSpeed)
assertEquals(false, s.voiceEnabledMaster)
assertEquals(true, s.voiceEnabledPlayer)
assertEquals(true, s.voiceWaitingNumber)
assertEquals("nam-minh", s.voice)
assertEquals(1.15f, s.boardTextScale)
}
@Test
fun `ignores an invalid stored color and keeps the default`() = runWithRepo { repository, store ->
store.edit { it[stringPreferencesKey("emptyCellColor")] = "not-a-color" }
assertEquals("#7030A0", repository.settingsFlow.first().emptyCellColor)
}
@Test
fun `rejects 3-digit hex shorthand`() = runWithRepo { repository, store ->
store.edit { it[stringPreferencesKey("emptyCellColor")] = "#fff" }
assertEquals("#7030A0", repository.settingsFlow.first().emptyCellColor)
}
@Test
fun `accepts uppercase hex`() = runWithRepo { repository, store ->
store.edit { it[stringPreferencesKey("emptyCellColor")] = "#ABCDEF" }
assertEquals("#ABCDEF", repository.settingsFlow.first().emptyCellColor)
}
@Test
fun `loads valid theme values`() = runWithRepo { repository, store ->
for (theme in ThemeSetting.entries) {
store.edit { it[stringPreferencesKey("theme")] = theme.storageValue }
assertEquals(theme, repository.settingsFlow.first().theme)
}
}
@Test
fun `falls back to default theme on invalid value`() = runWithRepo { repository, store ->
store.edit { it[stringPreferencesKey("theme")] = "neon" }
assertEquals(ThemeSetting.AUTO, repository.settingsFlow.first().theme)
}
@Test
fun `loads valid mode values`() = runWithRepo { repository, store ->
for (mode in AppMode.entries) {
store.edit { it[stringPreferencesKey("mode")] = mode.storageValue }
assertEquals(mode, repository.settingsFlow.first().mode)
}
}
@Test
fun `rejects invalid mode value`() = runWithRepo { repository, store ->
store.edit { it[stringPreferencesKey("mode")] = "spectator" }
assertEquals(AppMode.PLAYER, repository.settingsFlow.first().mode)
}
@Test
fun `loads autoCallSpeed in range 1 to 10`() = runWithRepo { repository, store ->
for (n in listOf(1, 5, 10)) {
store.edit { it[intPreferencesKey("autoCallSpeed")] = n }
assertEquals(n, repository.settingsFlow.first().autoCallSpeed)
}
}
@Test
fun `rejects out-of-range autoCallSpeed`() = runWithRepo { repository, store ->
for (bad in listOf(0, 11, -1)) {
store.edit { it[intPreferencesKey("autoCallSpeed")] = bad }
assertEquals(5, repository.settingsFlow.first().autoCallSpeed)
}
}
@Test
fun `invalid voice id falls back to default while other settings survive`() =
runWithRepo { repository, store ->
store.edit {
it[stringPreferencesKey("voice")] = "made-up-voice-id"
it[intPreferencesKey("autoCallSpeed")] = 7
}
val s = repository.settingsFlow.first()
assertEquals("hoai-my", s.voice)
assertEquals(7, s.autoCallSpeed)
}
@Test
fun `boardTextScale round-trips every allowed rung`() = runWithRepo { repository, _ ->
for (scale in Settings.BOARD_TEXT_SCALES) {
repository.setBoardTextScale(scale)
assertEquals(scale, repository.settingsFlow.first().boardTextScale)
}
}
@Test
fun `boardTextScale falls back to default for a value outside the allowlist`() =
runWithRepo { repository, store ->
store.edit { it[floatPreferencesKey("boardTextScale")] = 2.5f }
assertEquals(1f, repository.settingsFlow.first().boardTextScale)
}
@Test
fun `preserves a single old key without wiping it`() = runWithRepo { repository, store ->
store.edit { it[stringPreferencesKey("emptyCellColor")] = "#1e88e5" }
val s = repository.settingsFlow.first()
assertEquals("#1e88e5", s.emptyCellColor)
assertEquals(ThemeSetting.AUTO, s.theme)
assertEquals(AppMode.PLAYER, s.mode)
}
@Test
fun `reset returns all settings to defaults`() = runWithRepo { repository, _ ->
repository.setEmptyCellColor("#000000")
repository.setTheme(ThemeSetting.DARK)
repository.setMode(AppMode.BOTH)
repository.reset()
val s = repository.settingsFlow.first()
assertEquals("#7030A0", s.emptyCellColor)
assertEquals(ThemeSetting.AUTO, s.theme)
assertEquals(AppMode.PLAYER, s.mode)
}
}
@@ -0,0 +1,80 @@
package com.miti99.loto.state
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.miti99.loto.InMemoryDataStore
import com.miti99.loto.game.CardGenerator
import kotlin.random.Random
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class GameStateRepositoryTest {
@Test
fun `returns null when nothing is stored`() = runTest {
val repo = GameStateRepository(InMemoryDataStore())
assertNull(repo.loadPlayerState())
assertNull(repo.loadMasterState())
}
@Test
fun `player state survives a repository recreation (process death)`() = runTest {
val store = InMemoryDataStore()
val grid = CardGenerator.generateGrid(Random(1))
val crossed = grid.mapIndexed { r, row -> row.mapIndexed { c, _ -> (r + c) % 3 == 0 } }
val state = PlayerRoundState(grid, crossed, manualUnticks = setOf(7, 42, 88))
GameStateRepository(store).savePlayerState(state)
// Fresh repository over the same store = process restart.
assertEquals(state, GameStateRepository(store).loadPlayerState())
}
@Test
fun `master state survives a repository recreation`() = runTest {
val store = InMemoryDataStore()
val state = MasterRoundState(called = listOf(5, 88, 12), remaining = listOf(1, 2, 3))
GameStateRepository(store).saveMasterState(state)
assertEquals(state, GameStateRepository(store).loadMasterState())
}
@Test
fun `empty called and remaining lists round-trip (exhausted deck)`() = runTest {
val repo = GameStateRepository(InMemoryDataStore())
val state = MasterRoundState(called = (1..90).toList(), remaining = emptyList())
repo.saveMasterState(state)
assertEquals(state, repo.loadMasterState())
}
@Test
fun `corrupt payloads fall back to no saved round, never crash`() = runTest {
val store = InMemoryDataStore()
val repo = GameStateRepository(store)
val corruptGrids = listOf(
"1,2,junk", // non-numeric
"1,2,3", // wrong cell count
List(81) { "91" }.joinToString(","), // out of range
)
for (bad in corruptGrids) {
store.edit { it[stringPreferencesKey("player_grid")] = bad }
assertNull("grid payload: $bad", repo.loadPlayerState())
}
store.edit { it[stringPreferencesKey("master_called")] = "1,0,5" } // 0 out of range
assertNull(repo.loadMasterState())
}
@Test
fun `corrupt crossed bitmask falls back to an uncrossed board`() = runTest {
val store = InMemoryDataStore()
val repo = GameStateRepository(store)
val grid = CardGenerator.generateGrid(Random(2))
repo.savePlayerState(PlayerRoundState(grid, grid.map { r -> r.map { true } }, emptySet()))
store.edit { it[stringPreferencesKey("player_crossed")] = "10xx01" } // wrong length + charset
val restored = repo.loadPlayerState()
assertEquals(grid, restored?.grid)
assertEquals(grid.map { r -> r.map { false } }, restored?.crossed)
}
}
@@ -0,0 +1,184 @@
package com.miti99.loto.state
import com.miti99.loto.InMemoryDataStore
import com.miti99.loto.audio.FakeVoicePlayer
import com.miti99.loto.settings.Settings
import kotlin.random.Random
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class MasterPanelViewModelTest {
private val dispatcher = StandardTestDispatcher()
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
private class Env(
val masterStore: MasterStore,
val repository: GameStateRepository,
val settings: MutableStateFlow<Settings>,
val voice: FakeVoicePlayer,
val viewModel: MasterPanelViewModel,
)
private fun TestScope.env(initial: Settings): Env {
val repository = GameStateRepository(InMemoryDataStore())
val masterStore = MasterStore(repository, backgroundScope, DrawDeckSeeded)
val settings = MutableStateFlow(initial)
val voice = FakeVoicePlayer()
val viewModel = MasterPanelViewModel(masterStore, settings, voice)
runCurrent() // start the ticker collector
return Env(masterStore, repository, settings, voice, viewModel)
}
private val autoSettings =
Settings(voice = "hoai-my", autoCallEnabled = true, autoCallSpeed = 2)
@Test
fun `auto-call draws one number per interval`() = runTest(dispatcher) {
val env = env(autoSettings)
env.masterStore.startNewGame()
env.viewModel.toggleAuto()
runCurrent()
advanceTimeBy(2001)
assertEquals(1, env.masterStore.state.value.called.size)
advanceTimeBy(6000)
assertEquals(4, env.masterStore.state.value.called.size)
// Each draw was announced (master voice defaults on).
assertEquals(4, env.voice.utterances.size)
}
@Test
fun `mid-run speed change re-arms without double-ticking`() = runTest(dispatcher) {
val env = env(autoSettings)
env.masterStore.startNewGame()
env.viewModel.toggleAuto()
runCurrent()
advanceTimeBy(1000) // halfway through the 2s tick
assertEquals(0, env.masterStore.state.value.called.size)
env.settings.value = autoSettings.copy(autoCallSpeed = 5)
runCurrent()
// The old 2s timer must be gone…
advanceTimeBy(2001)
assertEquals(0, env.masterStore.state.value.called.size)
// …and the new 5s cadence takes over (restarted at the change).
advanceTimeBy(3000)
assertEquals(1, env.masterStore.state.value.called.size)
}
@Test
fun `auto-call stops when the deck is exhausted`() = runTest(dispatcher) {
val env = env(autoSettings)
env.repository.saveMasterState(
MasterRoundState(called = (1..89).toList(), remaining = listOf(90)),
)
env.masterStore.restore()
env.viewModel.toggleAuto()
runCurrent()
advanceTimeBy(2001)
assertEquals(90, env.masterStore.state.value.called.size)
advanceTimeBy(2001)
assertFalse(env.viewModel.autoRunning.value)
}
@Test
fun `disabling the auto setting mid-run stops the run`() = runTest(dispatcher) {
val env = env(autoSettings)
env.masterStore.startNewGame()
env.viewModel.toggleAuto()
runCurrent()
assertTrue(env.viewModel.autoRunning.value)
env.settings.value = autoSettings.copy(autoCallEnabled = false)
runCurrent()
assertFalse(env.viewModel.autoRunning.value)
advanceTimeBy(10_000)
assertEquals(0, env.masterStore.state.value.called.size)
}
@Test
fun `toggleAuto no-ops when no numbers remain`() = runTest(dispatcher) {
val env = env(autoSettings)
env.viewModel.toggleAuto() // no game yet — remaining is empty
runCurrent()
assertFalse(env.viewModel.autoRunning.value)
}
@Test
fun `newGame stops auto, cancels voice, and refills the deck`() = runTest(dispatcher) {
val env = env(autoSettings)
env.masterStore.startNewGame()
env.viewModel.toggleAuto()
runCurrent()
env.viewModel.newGame()
runCurrent()
assertFalse(env.viewModel.autoRunning.value)
assertEquals(emptyList<Int>(), env.masterStore.state.value.called)
assertEquals(90, env.masterStore.state.value.remaining.size)
}
@Test
fun `backgrounding pauses auto-call and returning resumes it`() = runTest(dispatcher) {
val env = env(autoSettings)
env.masterStore.startNewGame()
env.viewModel.toggleAuto()
runCurrent()
advanceTimeBy(2001)
assertEquals(1, env.masterStore.state.value.called.size)
env.viewModel.setForeground(false)
runCurrent()
// No draws while backgrounded — but autoRunning stays on.
advanceTimeBy(10_000)
assertEquals(1, env.masterStore.state.value.called.size)
assertTrue(env.viewModel.autoRunning.value)
env.viewModel.setForeground(true)
runCurrent()
advanceTimeBy(2001)
assertEquals(2, env.masterStore.state.value.called.size)
}
@Test
fun `drawNext announces only when the master voice is enabled`() = runTest(dispatcher) {
val env = env(autoSettings.copy(voiceEnabledMaster = false))
env.masterStore.startNewGame()
env.viewModel.drawNext()
runCurrent()
assertEquals(1, env.masterStore.state.value.called.size)
assertEquals(0, env.voice.utterances.size)
assertEquals(1, env.viewModel.tickKey.value)
}
private companion object {
val DrawDeckSeeded get() = com.miti99.loto.game.DrawDeck(Random(1234))
}
}
@@ -0,0 +1,306 @@
package com.miti99.loto.state
import com.miti99.loto.InMemoryDataStore
import com.miti99.loto.audio.FakeVoicePlayer
import com.miti99.loto.settings.AppMode
import com.miti99.loto.settings.Settings
import kotlin.random.Random
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class PlayerBoardViewModelTest {
private val dispatcher = StandardTestDispatcher()
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
private class Env(
val repository: GameStateRepository,
val masterStore: MasterStore,
val settings: MutableStateFlow<Settings>,
val voice: FakeVoicePlayer,
val viewModel: PlayerBoardViewModel,
)
private fun TestScope.env(initial: Settings): Env {
val repository = GameStateRepository(InMemoryDataStore())
val masterStore =
MasterStore(repository, backgroundScope, com.miti99.loto.game.DrawDeck(Random(7)))
val settings = MutableStateFlow(initial)
val voice = FakeVoicePlayer()
val viewModel = PlayerBoardViewModel(repository, masterStore, settings, voice)
runCurrent() // run restore + start the master collector
return Env(repository, masterStore, settings, voice, viewModel)
}
private val playerVoiceOn = Settings(voice = "hoai-my", voiceEnabledPlayer = true)
/** Row-0 cells (col to number) of the current grid. */
private fun rowCells(env: Env, row: Int): List<Pair<Int, Int>> {
val grid = env.viewModel.uiState.value.grid!!
return grid[row].withIndex().filter { it.value > 0 }.map { it.index to it.value }
}
@Test
fun `generate publishes a fresh 9x9 grid with no marks`() = runTest(dispatcher) {
val env = env(playerVoiceOn)
env.viewModel.generate()
runCurrent()
val state = env.viewModel.uiState.value
assertEquals(9, state.grid?.size)
assertTrue(state.crossed.flatten().none { it })
assertEquals(List(9) { false }, state.rowComplete)
}
@Test
fun `fourth mark in a row raises the chờ toast and announces it`() = runTest(dispatcher) {
val env = env(playerVoiceOn)
env.viewModel.generate()
runCurrent()
val cells = rowCells(env, 0)
for ((col, _) in cells.take(4)) {
env.viewModel.onCellClick(0, col)
}
runCurrent()
val waitingNumber = cells[4].second
assertEquals("Chờ $waitingNumber", env.viewModel.uiState.value.toast)
// voiceWaitingNumber off → only the "cho" clip.
assertEquals(listOf("cho"), env.voice.utterances.last())
// The awaited cell pulses and its section rings.
assertTrue(
env.viewModel.uiState.value.waitingCells
.contains(com.miti99.loto.game.PlayerCard.Cell(0, cells[4].first)),
)
assertTrue(env.viewModel.uiState.value.sectionWaiting[0])
}
@Test
fun `toast auto-dismisses after five seconds`() = runTest(dispatcher) {
val env = env(playerVoiceOn)
env.viewModel.generate()
runCurrent()
rowCells(env, 0).take(4).forEach { (col, _) -> env.viewModel.onCellClick(0, col) }
runCurrent()
assertNotNull(env.viewModel.uiState.value.toast)
advanceTimeBy(5001)
assertNull(env.viewModel.uiState.value.toast)
}
@Test
fun `completing a row shows the Kinh modal and announces kinh`() = runTest(dispatcher) {
val env = env(playerVoiceOn)
env.viewModel.generate()
runCurrent()
rowCells(env, 0).forEach { (col, _) -> env.viewModel.onCellClick(0, col) }
runCurrent()
val state = env.viewModel.uiState.value
assertTrue(state.showCongrats)
assertEquals(1, state.congratsRow)
assertEquals(listOf("kinh"), env.voice.utterances.last())
assertTrue(state.rowComplete[0])
}
@Test
fun `no announcements when voice flags are off, toast still shows`() = runTest(dispatcher) {
val env = env(Settings(voice = "hoai-my")) // player voice off, mode player
env.viewModel.generate()
runCurrent()
rowCells(env, 0).take(4).forEach { (col, _) -> env.viewModel.onCellClick(0, col) }
runCurrent()
assertNotNull(env.viewModel.uiState.value.toast)
assertEquals(0, env.voice.utterances.size)
}
@Test
fun `master draws auto-cross the whole board in both mode`() = runTest(dispatcher) {
val env = env(playerVoiceOn.copy(mode = AppMode.BOTH))
env.viewModel.generate()
runCurrent()
env.masterStore.startNewGame()
repeat(90) { env.masterStore.drawNext() }
runCurrent()
val state = env.viewModel.uiState.value
val grid = state.grid!!
for (r in grid.indices) {
for (c in grid[r].indices) {
if (grid[r][c] > 0) {
assertTrue("cell $r,$c should be crossed", state.crossed[r][c])
}
}
}
assertEquals(List(9) { true }, state.rowComplete)
}
@Test
fun `master Ván mới clears player marks in both mode`() = runTest(dispatcher) {
val env = env(playerVoiceOn.copy(mode = AppMode.BOTH))
env.viewModel.generate()
runCurrent()
env.masterStore.startNewGame()
repeat(30) { env.masterStore.drawNext() }
runCurrent()
assertTrue(env.viewModel.uiState.value.crossed.flatten().any { it })
env.masterStore.startNewGame() // called goes >0 → 0
runCurrent()
assertTrue(env.viewModel.uiState.value.crossed.flatten().none { it })
}
@Test
fun `mode=player ignores master draws`() = runTest(dispatcher) {
val env = env(playerVoiceOn) // mode defaults to PLAYER
env.viewModel.generate()
runCurrent()
env.masterStore.startNewGame()
repeat(90) { env.masterStore.drawNext() }
runCurrent()
assertTrue(env.viewModel.uiState.value.crossed.flatten().none { it })
}
@Test
fun `kill and restore brings the exact round back`() = runTest(dispatcher) {
val env = env(playerVoiceOn)
env.viewModel.generate()
runCurrent()
val cells = rowCells(env, 0)
cells.take(3).forEach { (col, _) -> env.viewModel.onCellClick(0, col) }
advanceUntilIdle() // let persistence writes land
val before = env.viewModel.uiState.value
// Second ViewModel over the same repository = process restart.
val restored = PlayerBoardViewModel(env.repository, env.masterStore, env.settings, env.voice)
advanceUntilIdle()
assertEquals(before.grid, restored.uiState.value.grid)
assertEquals(before.crossed, restored.uiState.value.crossed)
}
@Test
fun `restore does not re-announce an already-waiting row`() = runTest(dispatcher) {
val env = env(playerVoiceOn)
env.viewModel.generate()
runCurrent()
rowCells(env, 0).take(4).forEach { (col, _) -> env.viewModel.onCellClick(0, col) }
advanceUntilIdle()
val announced = env.voice.utterances.size
val restored = PlayerBoardViewModel(env.repository, env.masterStore, env.settings, env.voice)
advanceUntilIdle()
assertEquals(announced, env.voice.utterances.size)
assertNull(restored.uiState.value.toast)
}
@Test
fun `manually unticked called number stays unticked across restore-replay`() =
runTest(dispatcher) {
val env = env(playerVoiceOn.copy(mode = AppMode.BOTH))
env.viewModel.generate()
runCurrent()
env.masterStore.startNewGame()
repeat(90) { env.masterStore.drawNext() } // everything crossed
runCurrent()
val grid = env.viewModel.uiState.value.grid!!
val num = grid[0].first { it > 0 }
val col = grid[0].indexOf(num)
env.viewModel.onCellClick(0, col) // manual untick of a called number
advanceUntilIdle()
assertFalse(env.viewModel.uiState.value.crossed[0][col])
// Process restart replays the full call history from cursor 0 —
// the persisted untick must suppress the re-cross…
val restored =
PlayerBoardViewModel(env.repository, env.masterStore, env.settings, env.voice)
advanceUntilIdle()
assertFalse(restored.uiState.value.crossed[0][col])
// …while every other number stays crossed.
val others = grid.flatten().count { it > 0 } - 1
assertEquals(others, restored.uiState.value.crossed.flatten().count { it })
}
@Test
fun `clearMarks replay that completes a row fires Kinh immediately`() =
runTest(dispatcher) {
val env = env(playerVoiceOn.copy(mode = AppMode.BOTH))
env.viewModel.generate()
runCurrent()
env.masterStore.startNewGame()
repeat(90) { env.masterStore.drawNext() } // board fully crossed
runCurrent()
env.viewModel.dismissCongrats()
val announcedBefore = env.voice.utterances.size
// The clear-replay instantly re-completes every row — the Kinh
// modal and clip must fire from the replay itself, not wait for
// a next draw that may never come.
env.viewModel.clearMarks()
runCurrent()
assertTrue(env.viewModel.uiState.value.showCongrats)
assertEquals(listOf("kinh"), env.voice.utterances.last())
assertTrue(env.voice.utterances.size > announcedBefore)
}
@Test
fun `generate mid-game announces a chờ landed by the replay`() = runTest(dispatcher) {
val env = env(playerVoiceOn.copy(mode = AppMode.BOTH))
env.viewModel.generate()
runCurrent()
env.masterStore.startNewGame()
repeat(90) { env.masterStore.drawNext() }
runCurrent()
env.viewModel.dismissCongrats()
// Regenerating against a fully-called history completes every row on
// the fresh card too — detection must run as part of generate().
env.viewModel.generate()
runCurrent()
assertTrue(env.viewModel.uiState.value.showCongrats)
assertEquals(listOf("kinh"), env.voice.utterances.last())
}
@Test
fun `clearMarks in both mode re-crosses all called numbers (unticks wiped)`() =
runTest(dispatcher) {
val env = env(playerVoiceOn.copy(mode = AppMode.BOTH))
env.viewModel.generate()
runCurrent()
env.masterStore.startNewGame()
repeat(90) { env.masterStore.drawNext() }
runCurrent()
val grid = env.viewModel.uiState.value.grid!!
val col = grid[0].indexOfFirst { it > 0 }
env.viewModel.onCellClick(0, col) // manual untick
runCurrent()
// Clear wipes the untick memory before replaying, so the whole
// board re-crosses (locked product decision, mirrors the web).
env.viewModel.clearMarks()
runCurrent()
assertTrue(env.viewModel.uiState.value.crossed[0][col])
}
}
@@ -0,0 +1,26 @@
package com.miti99.loto.ui
import com.miti99.loto.settings.AppMode
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class KeepScreenOnTest {
@Test
fun `held while a master round has numbers left in master and both modes`() {
assertTrue(shouldKeepScreenOn(AppMode.MASTER, remainingCount = 90))
assertTrue(shouldKeepScreenOn(AppMode.BOTH, remainingCount = 1))
}
@Test
fun `released when the deck is exhausted or no round exists`() {
assertFalse(shouldKeepScreenOn(AppMode.MASTER, remainingCount = 0))
assertFalse(shouldKeepScreenOn(AppMode.BOTH, remainingCount = 0))
}
@Test
fun `never held in player-only mode`() {
assertFalse(shouldKeepScreenOn(AppMode.PLAYER, remainingCount = 90))
}
}
@@ -0,0 +1,41 @@
package com.miti99.loto.ui.master
import org.junit.Assert.assertEquals
import org.junit.Test
/** Ones-digit cell mapping of the 11×9 master board, including the 90 edge cell. */
class MasterBoardLayoutTest {
private val board = MasterBoardLayout.BOARD
@Test
fun `board is 11 rows by 9 columns`() {
assertEquals(11, board.size)
board.forEach { assertEquals(9, it.size) }
}
@Test
fun `row 0 holds the round tens, col 0 blank`() {
assertEquals(listOf(0, 10, 20, 30, 40, 50, 60, 70, 80), board[0])
}
@Test
fun `rows 1-9 hold tens+ones with col 0 as bare ones digit`() {
assertEquals(listOf(1, 11, 21, 31, 41, 51, 61, 71, 81), board[1])
assertEquals(listOf(9, 19, 29, 39, 49, 59, 69, 79, 89), board[9])
assertEquals(45, board[5][4])
}
@Test
fun `90 sits alone in row 10 col 8`() {
assertEquals(90, board[10][8])
assertEquals(List(8) { 0 }, board[10].take(8))
}
@Test
fun `board holds every number 1 to 90 exactly once`() {
val nums = board.flatten().filter { it > 0 }
assertEquals(90, nums.size)
assertEquals((1..90).toSet(), nums.toSet())
}
}