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.
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()
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
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>
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||