diff --git a/android/plans/260429-1511-loto-upstream-sync/phase-01-confetti-and-celebration.md b/android/plans/260429-1511-loto-upstream-sync/phase-01-confetti-and-celebration.md new file mode 100644 index 0000000..e99fbe0 --- /dev/null +++ b/android/plans/260429-1511-loto-upstream-sync/phase-01-confetti-and-celebration.md @@ -0,0 +1,112 @@ +# Phase 01 — Confetti & celebration tier rule + +## Context + +Upstream commit `a60ea08` (2026-04-28). Two changes bundled here: + +1. **Tier rule**: confetti now fires on the 2nd bingo, OR on the 1st bingo + when another row is in Chờ. Old rule (`>= 3`) rarely fired on a 9-row + card, so most wins felt under-celebrated. +2. **Confetti emoji set + size jitter**: add 🥢 🎋 🏮 (chopsticks, bamboo, + lantern) for hội-chợ flavour; per-piece size 1.5–2.4 sp scale. + +## Priority + +P0 — game feel. + +## Status + +pending. + +## Key insights + +- The "Chờ" check needs the live grid + crossed state at bingo time. The VM + already has both in `_state.value`. We have to compute "is any *other* + row in Chờ" at the moment we evaluate the tier (just after marking the + new bingo into `celebratedRows`). +- Compose can change `Text.fontSize` per particle without a `--size` CSS + variable equivalent — keep the size jitter inside `Particle.fontSizeSp`. + +## Related code files + +- `app/src/main/java/com/miti99/loto/state/PlayerBoardViewModel.kt` +- `app/src/main/java/com/miti99/loto/ui/board/Confetti.kt` + +## Implementation steps + +1. **PlayerBoardViewModel.kt — tier rule** + + Replace `applyCrossedChange` Pass-1 tier calculation: + + ```kotlin + // OLD (line ~166): + // val tier = if (celebrated.size >= 3) 2 else 1 + + val newCelebrated = celebrated + i + val hasActiveCho = grid.indices.any { otherRow -> + otherRow !in newCelebrated && + GameLogic.getWaitingNumber(gridArr, crossedArr, otherRow) != null + } + val tier = when { + newCelebrated.size >= 2 -> 2 + newCelebrated.size >= 1 && hasActiveCho -> 2 + else -> 1 + } + celebrated = newCelebrated + ``` + + Note: `crossedArr` in this scope already reflects the new mark for row + `i`, so `getWaitingNumber` returns null for the just-completed row — + the `otherRow !in newCelebrated` filter is belt-and-braces. + +2. **Confetti.kt — emoji + size jitter** + + ```kotlin + private val CONFETTI_EMOJI = listOf("🎊", "✨", "🎉", "🥳", "🥢", "🎋", "🏮") + + private data class Particle( + val emoji: String, + val startXFraction: Float, + val speed: Float, + val rotationSpeed: Float, + val delayNs: Long, + val fontSizeSp: Float, // NEW + ) + ``` + + In the `remember { List(...) }` initializer, derive + `fontSizeSp = 1.5f + ((i * 13) % 11) / 10f` then `* 16f` for sp scale — + final clamp to `[24f, 38f]`. Pass into `Text(fontSize = p.fontSizeSp.sp)`. + +3. Compile + run: + + ```bash + ./gradlew :app:compileDebugKotlin + ``` + +## Todo + +- [ ] Update tier rule in PlayerBoardViewModel.kt +- [ ] Add 🥢 🎋 🏮 to CONFETTI_EMOJI +- [ ] Add `fontSizeSp` to Particle and use it in Text +- [ ] Compile clean + +## Success criteria + +- 1st bingo with another row in Chờ → `tier == 2` (Confetti fires). +- 2nd bingo (regardless of Chờ) → `tier == 2`. +- 1st bingo with no Chờ row → `tier == 1` (no Confetti). +- Confetti renders 7 distinct emoji across particles, with visible size + variance. + +## Risks + +- `getWaitingNumber` is a 9-col scan per row; called 9 times per bingo — + trivial cost. No mitigation needed. +- A row could have just been completed by the same draw that triggers the + bingo; the `otherRow !in newCelebrated` filter prevents that row from + counting itself toward `hasActiveCho`. + +## Next + +Phase 02 (section Chờ ring) — independent, can run in parallel if needed. diff --git a/android/plans/260429-1511-loto-upstream-sync/phase-02-section-cho-ring.md b/android/plans/260429-1511-loto-upstream-sync/phase-02-section-cho-ring.md new file mode 100644 index 0000000..e76f3a9 --- /dev/null +++ b/android/plans/260429-1511-loto-upstream-sync/phase-02-section-cho-ring.md @@ -0,0 +1,141 @@ +# Phase 02 — Section "Chờ" ring on label band + +## Context + +Upstream commit `a60ea08` (2026-04-28) part 1: each section's label band +glows amber while any of its 3 rows is one cell from bingo and not yet +complete. Reduces reliance on the 5 s waiting toast. + +## Priority + +P1 — UX nice-to-have, reinforces existing toast. + +## Status + +pending. + +## Key insights + +- Web computes `waitingRows[r]` per row, then `sectionHasWaiting[s]` = + any of rows `[s*3 .. s*3+2]` waiting. Same shape works in VM. +- The flag must be derived from the same snapshot as `crossed`/`grid`. + Cleanest is a derived field in `PlayerBoardUiState` recomputed inside + `applyCrossedChange` and `loadFromStorage`. +- Compose has no `prefers-reduced-motion` CSS query. Use + `LocalAccessibilityManager.current` (or similar `Settings.Global` read) + to detect reduce-motion. Default to a static glow with no pulse if + detection is too noisy. + +## Related code files + +- `app/src/main/java/com/miti99/loto/state/PlayerBoardUiState.kt` +- `app/src/main/java/com/miti99/loto/state/PlayerBoardViewModel.kt` +- `app/src/main/java/com/miti99/loto/ui/board/PlayerBoardGrid.kt` +- `app/src/main/java/com/miti99/loto/ui/theme/Color.kt` (amber band color, may + reuse `BrandAmberLight`) + +## Implementation steps + +1. **State shape** (`PlayerBoardUiState.kt`): + + ```kotlin + val sectionHasWaiting: List = listOf(false, false, false), + ``` + +2. **VM derivation** (`PlayerBoardViewModel.kt`): + + - In `applyCrossedChange` (after computing `rowComplete`), add: + + ```kotlin + val waitingRows = grid.indices.map { r -> + !rowComplete[r] && + GameLogic.getWaitingNumber(gridArr, crossedArr, r) != null + } + val sectionHasWaiting = listOf(0, 3, 6).map { start -> + (start until start + 3).any { r -> waitingRows.getOrElse(r) { false } } + } + ``` + + - Pass `sectionHasWaiting = sectionHasWaiting` into the `_state.update` + copy. + - Mirror the same derivation in `loadFromStorage`, `clear`, `generate` + (last two reset to `[false, false, false]` since no marks yet). + +3. **UI** (`PlayerBoardGrid.kt`): + + Change the section-label `Text` to a `Box` wrapping the label, and + apply a `border` + glow when `sectionHasWaiting[sectionIdx]`: + + ```kotlin + val isWaiting = sectionHasWaiting.getOrNull(sectionIdx) ?: false + val infinite = rememberInfiniteTransition(label = "cho-pulse") + val alpha by infinite.animateFloat( + initialValue = 0.55f, targetValue = 1.0f, + animationSpec = infiniteRepeatable( + tween(1500, easing = LinearEasing), RepeatMode.Reverse, + ), + label = "cho-alpha", + ) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(start = 4.dp, top = if (sectionIdx > 0) 12.dp else 4.dp, bottom = 2.dp) + .then( + if (isWaiting) Modifier.background( + color = BrandAmberLight.copy(alpha = alpha * 0.18f), + shape = RoundedCornerShape(8.dp), + ).border( + width = 1.dp, + color = BrandAmberLight.copy(alpha = alpha), + shape = RoundedCornerShape(8.dp), + ).padding(horizontal = 8.dp, vertical = 2.dp) + else Modifier + ), + ) { + Text( + text = stringResource(SECTION_LABEL_RES[sectionIdx]), + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = if (isWaiting) BrandAmberLight else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + ``` + + - Update `PlayerBoardGrid` signature to accept + `sectionHasWaiting: List`. + - Caller (`PlayerBoardScreen`) passes `state.sectionHasWaiting`. + +4. **Reduce-motion guard**: + + - Wrap the `infiniteTransition` block in + `if (LocalAccessibilityManager.current?.isEnabled != true) infinite else stillPulse`, + OR fall back to a static `alpha = 0.85f` for users with reduce-motion + enabled. Implementation choice is fine either way; prefer the simple + static fallback unless QA disagrees. + +## Todo + +- [ ] Add `sectionHasWaiting` to `PlayerBoardUiState` +- [ ] Compute it in VM (apply, load, clear, generate) +- [ ] Update `PlayerBoardGrid` signature + render +- [ ] Wire from `PlayerBoardScreen` +- [ ] Reduce-motion fallback + +## Success criteria + +- A row with exactly 1 uncrossed number flips its section label to amber + glow; manually un-checking a different cell that breaks Chờ removes the + glow. +- All 3 sections light up if all of them have a waiting row. +- A bingo'd section drops its glow once the row completes. +- No glow on cold start (no grid). + +## Risks + +- Compose `infiniteTransition` keeps recomposing while visible. Confirm + this doesn't cause bad battery on long sessions — measure once during + QA. If problematic, drop to a single-shot fade-in static state. + +## Next + +Phase 03 (palette). diff --git a/android/plans/260429-1511-loto-upstream-sync/phase-03-solid-color-palette.md b/android/plans/260429-1511-loto-upstream-sync/phase-03-solid-color-palette.md new file mode 100644 index 0000000..5287542 --- /dev/null +++ b/android/plans/260429-1511-loto-upstream-sync/phase-03-solid-color-palette.md @@ -0,0 +1,150 @@ +# Phase 03 — Solid-color palette refactor + +## Context + +Upstream commit `2fb35f2` (2026-04-29). New single-accent system: + +- **rose-600** `#E11D48` — player + UI primary +- **amber-600** `#D97706` — host primary +- **emerald-600** `#059669` — draw / completion +- **sky-600** `#0284C7` — master ≤49 token (replaces pink-500) +- Background warmer cream `#fffbeb` (light); dark unchanged (`#050813`). +- All ad-hoc gradients dropped EXCEPT the wordmark (intentional brand + signature; web also kept its rose→amber wordmark). + +## Priority + +P1 — visual cohesion. + +## Status + +pending. + +## Key insights + +- Android already has `BrandRoseLight/Dark`, `BrandAmberLight/Dark`, + `BrandEmeraldLight/Dark`. We need the deeper `-600` shades AND new + `BrandSky*` constants. +- `BrandPink*` and `BrandIndigo*` become unused after this phase. Delete + them outright (`docs/development-rules.md`: "do not leave unused"), + unless any test asserts on the old hex. +- "Ván mới" button currently uses `Brush.horizontalGradient(amber, rose)` + — flatten to solid `BrandAmber600`. +- The crossed-cell red diagonal slash is a `Canvas` line in Compose + already — no SVG migration needed; it's already a stroked line, not a + gradient. + +## Related code files + +- `ui/theme/Color.kt` — add `BrandSky*`, `BrandRose600`, `BrandAmber600`, + `BrandEmerald600`, `BackgroundCream`. Remove `BrandPink*`, `BrandIndigo*` + once unused. +- `ui/theme/Theme.kt` — switch light `background` to `BackgroundCream`. +- `ui/master/MasterCell.kt`, `ui/master/CalledHistory.kt`, + `ui/master/CurrentNumberHero.kt` — replace `BrandPinkLight` with + `BrandSky600`. +- `ui/master/MasterControls.kt` — drop the gradient `Box` wrapper; use a + plain `Button(containerColor = BrandAmber600)`. +- `ui/board/PlayerBoardScreen.kt` — replace `BrandIndigoLight` (clear + button) with `BrandRose600`. Adjust `Generate` button to `BrandRose600` + if currently `BrandRoseLight`. +- `ui/board/KinhModal.kt` — drop the gradient `Brush` on "Kinh!" title; + use solid `BrandAmber600` (matches web's text-amber-600). + +## Implementation steps + +1. **Color.kt — add deeper shades** + + ```kotlin + // Solid-palette shades (matches Tailwind -600 step) + val BrandRose600 = Color(0xFFE11D48) + val BrandAmber600 = Color(0xFFD97706) + val BrandEmerald600 = Color(0xFF059669) + val BrandSky600 = Color(0xFF0284C7) + val BrandSky400 = Color(0xFF38BDF8) // dark variant + val BackgroundCream = Color(0xFFFFFBEB) + ``` + + After all consumers migrate, delete: + + ```kotlin + // val BrandPinkLight, BrandPinkDark + // val BrandIndigoLight, BrandIndigoDark + ``` + +2. **Theme.kt — light background** + + ```kotlin + private val LotoLightColorScheme = lightColorScheme( + // ... + background = BackgroundCream, // was BackgroundLight + // ... + ) + ``` + +3. **Master ≤49 token color** — every `BrandPinkLight` → `BrandSky600` + (light theme). For dark theme, use `BrandSky400`. Wire the dark + variant via `if (darkTheme) BrandSky400 else BrandSky600` at the + call site. + +4. **MasterControls.kt — flatten "Ván mới"** + + Replace the `Box { Button(transparent) }` wrapper with: + + ```kotlin + Button( + onClick = onNewGame, + colors = ButtonDefaults.buttonColors( + containerColor = BrandAmber600, + contentColor = Color.White, + ), + modifier = Modifier.weight(1f), + ) { + Text(stringResource(R.string.btn_new_game), fontWeight = FontWeight.Bold) + } + ``` + + Remove the `Brush.horizontalGradient` import. + +5. **PlayerBoardScreen.kt — clear button** + + Replace `BrandIndigoLight` with `BrandRose600`. Use `OutlinedButton` + contentColor. + +6. **KinhModal.kt — flatten title** + + Replace `Brush.horizontalGradient` with a solid `BrandAmber600` text + color. Drop the gradient brush import. + +## Todo + +- [ ] Color.kt: add 6 new tokens +- [ ] Theme.kt: light background → cream +- [ ] MasterCell.kt: pink → sky (theme-aware) +- [ ] CalledHistory.kt: pink → sky +- [ ] CurrentNumberHero.kt: pink → sky +- [ ] MasterControls.kt: drop gradient on "Ván mới" +- [ ] PlayerBoardScreen.kt: indigo → rose on clear button +- [ ] KinhModal.kt: drop gradient on "Kinh!" +- [ ] Color.kt: delete BrandPink/Indigo once unused +- [ ] Compile clean + +## Success criteria + +- No `Brush.horizontalGradient` import outside `Wordmark.kt`. +- "Ván mới" renders as solid amber, no orange→red split. +- Master ≤49 tokens render sky-blue, not pink. +- Light-mode background is cream (`#fffbeb`). +- "Kinh!" title is solid amber. +- `grep -r BrandPink` and `grep -r BrandIndigo` return only theme-deletion + candidates (or nothing after cleanup). + +## Risks + +- KinhModal Compose render previews may need re-snapshot if any exist. +- Dark-mode contrast: sky-600 on amber-cream cell could under-contrast. + Use sky-400 in dark mode (we add `BrandSky400` for that). + +## Next + +Phase 04 (voice-master hint copy). diff --git a/android/plans/260429-1511-loto-upstream-sync/phase-04-voice-hint-per-mode.md b/android/plans/260429-1511-loto-upstream-sync/phase-04-voice-hint-per-mode.md new file mode 100644 index 0000000..adf5935 --- /dev/null +++ b/android/plans/260429-1511-loto-upstream-sync/phase-04-voice-hint-per-mode.md @@ -0,0 +1,123 @@ +# Phase 04 — Per-mode voice-master hint copy + +## Context + +Upstream commit `ee71bf0` (2026-04-27, after port baseline). The single +hint string was wrong in master-only mode (no player board → no +Chờ/Kinh). Web split into two: + +- mode `both` → "Đọc số đã xổ + báo Chờ/Kinh." +- mode `master` → "Đọc số đã xổ." + +Android currently shows no hint at all under the master-voice toggle. + +## Priority + +P2 — copy correctness. + +## Status + +pending. + +## Key insights + +- The hint should only render when `mode != PLAYER` (matches web's outer + `{#if settings.mode !== "player"}`). Android `VoiceToggles` doesn't + currently gate the master switch by mode — confirm the row is intended + to be visible in master + both, hidden in player. Web hides it in + player. Port that gating too. + +## Related code files + +- `app/src/main/res/values/strings.xml` +- `app/src/main/java/com/miti99/loto/ui/settings/VoiceToggles.kt` +- `app/src/main/java/com/miti99/loto/ui/settings/SettingsSheet.kt` + (verify nothing else duplicates the hint) + +## Implementation steps + +1. **strings.xml — add hints + mode-gated flag** + + ```xml + + Đọc số đã xổ + báo Chờ/Kinh. + Đọc số đã xổ. + ``` + +2. **VoiceToggles.kt — gate + hint** + + ```kotlin + @Composable + internal fun VoiceToggles( + state: SettingsState, + onSetVoiceEnabledMaster: (Boolean) -> Unit, + onSetVoiceEnabledPlayer: (Boolean) -> Unit, + onSetVoiceWaitingNumber: (Boolean) -> Unit, + modifier: Modifier = Modifier, + ) { + Column(modifier = modifier) { + if (state.mode != SettingsState.Mode.PLAYER) { + SwitchRow( + label = stringResource(R.string.voice_enabled_master), + checked = state.voiceEnabledMaster, + onCheckedChange = onSetVoiceEnabledMaster, + ) + val hintRes = if (state.mode == SettingsState.Mode.BOTH) + R.string.voice_master_hint_both + else + R.string.voice_master_hint_master_only + Text( + text = stringResource(hintRes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 4.dp, top = 2.dp, bottom = 8.dp), + ) + } + + if (state.mode != SettingsState.Mode.MASTER) { + SwitchRow( + label = stringResource(R.string.voice_enabled_player), + checked = state.voiceEnabledPlayer, + onCheckedChange = onSetVoiceEnabledPlayer, + ) + AnimatedVisibility(visible = state.voiceEnabledPlayer) { + SwitchRow( + label = stringResource(R.string.voice_waiting_number), + checked = state.voiceWaitingNumber, + onCheckedChange = onSetVoiceWaitingNumber, + ) + } + } + } + } + ``` + + Note the player toggle gating mirrors web (hide in master-only mode). + Confirm with `MasterPanel.svelte` / `PlayerBoard.svelte` rendering + logic — web hides the player row in `mode === "master"`. + +## Todo + +- [ ] Add 2 strings +- [ ] Gate master row by mode +- [ ] Render per-mode hint under master row +- [ ] Gate player rows by mode (hide in master-only) + +## Success criteria + +- Mode `player` → only the player switch + (conditional) "đọc số khi Chờ" + visible. No master switch. +- Mode `master` → only the master switch + correct hint visible. +- Mode `both` → both switches visible; hint shows the "+ báo Chờ/Kinh." + variant. +- Hint typography matches the rest of the settings sheet ("body-small, + on-surface-variant" — the existing convention). + +## Risks + +- If any test asserts `voice_enabled_master` is always present, it will + fail in mode=player. Update phase-06 test plan accordingly. + +## Next + +Phase 05 (optional) or Phase 06 directly. diff --git a/android/plans/260429-1511-loto-upstream-sync/phase-05-extract-auto-tick.md b/android/plans/260429-1511-loto-upstream-sync/phase-05-extract-auto-tick.md new file mode 100644 index 0000000..23c2266 --- /dev/null +++ b/android/plans/260429-1511-loto-upstream-sync/phase-05-extract-auto-tick.md @@ -0,0 +1,123 @@ +# Phase 05 — Extract auto-tick into pure helper (OPTIONAL) + +## Context + +Upstream commit `3e6cb90` (2026-04-28). Pulled the bus-driven auto-tick +effect body out of `PlayerBoard.svelte` into `src/lib/auto-tick.js` so +the dedup-by-`at` invariant — which already caught a P0 — is unit-testable +without mounting the component. + +Android equivalent: `PlayerBoardViewModel.consumeBusEvent`. It already +encodes the dedup invariant via `lastConsumedEventId`. **Behavior is +already correct.** This phase is a refactor for testability parity. + +## Priority + +P3 — optional. Skip without consequence; the invariant is already +exercised in `PlayerBoardViewModelTest`. + +## Status + +pending. + +## Decision + +Recommend **defer** this phase unless a test gap emerges during phase 06. +The Kotlin VM-level test already covers monotonic-id dedup — extracting a +pure helper just shuffles the test boundary. + +If we later want test parity: + +## Implementation outline (if adopted) + +1. **Create** `app/src/main/java/com/miti99/loto/state/AutoTick.kt`: + + ```kotlin + package com.miti99.loto.state + + import com.miti99.loto.settings.SettingsState + + data class AutoTickResult( + val crossed: List>, + val lastConsumedEventId: Long, + val changed: Boolean, + ) + + /** + * Pure auto-tick rule — port of upstream `processAutoTick`. + * Always advances `lastConsumedEventId` on any NEW event (id > last) + * even when no cell flips, so reactive re-runs of cross/grid changes + * don't re-fire the same event. + */ + fun processAutoTick( + grid: List>?, + crossed: List>, + event: DrawEvent?, + lastConsumedEventId: Long, + mode: SettingsState.Mode, + ): AutoTickResult { + if (event == null || event.id <= lastConsumedEventId) { + return AutoTickResult(crossed, lastConsumedEventId, false) + } + val advanced = event.id + if (mode != SettingsState.Mode.BOTH) { + return AutoTickResult(crossed, advanced, false) + } + if (grid == null || crossed.isEmpty()) { + return AutoTickResult(crossed, advanced, false) + } + var hit = false + val updated = crossed.mapIndexed { r, line -> + line.mapIndexed { c, v -> + if (!hit && grid[r][c] == event.num && !v) { hit = true; true } else v + } + } + return if (hit) AutoTickResult(updated, advanced, true) + else AutoTickResult(crossed, advanced, false) + } + ``` + +2. **Refactor** `PlayerBoardViewModel.consumeBusEvent` to call it: + + ```kotlin + private fun consumeBusEvent(event: DrawEvent) { + val cur = _state.value + val mode = currentSettings().mode + val r = processAutoTick( + grid = cur.grid, + crossed = cur.crossed, + event = event, + lastConsumedEventId = cur.lastConsumedEventId, + mode = mode, + ) + if (r.changed) applyCrossedChange(r.crossed, r.lastConsumedEventId) + else _state.update { it.copy(lastConsumedEventId = r.lastConsumedEventId) } + } + ``` + +3. **Tests** in `app/src/test/java/com/miti99/loto/state/AutoTickTest.kt`: + port the 8 vitest cases from `auto-tick.test.js` (NEW draw, dedup, + re-cross after manual untick, mode=master/player ignored, off-board, + null event, null grid, advance even on miss). + +## Todo + +- [ ] Create AutoTick.kt +- [ ] Refactor consumeBusEvent +- [ ] Port 8 tests +- [ ] Compile + tests pass + +## Success criteria + +- All existing PlayerBoardViewModelTest cases still pass. +- New AutoTickTest passes 8/8. +- `consumeBusEvent` body shrinks to ≤ 12 lines. + +## Risks + +- None — pure refactor. Behavior change is impossible (function returns + exactly the same `(crossed, lastConsumedEventId, changed)` tuple). + +## Next + +Phase 06 (tests + verification). diff --git a/android/plans/260429-1511-loto-upstream-sync/phase-06-tests-and-verification.md b/android/plans/260429-1511-loto-upstream-sync/phase-06-tests-and-verification.md new file mode 100644 index 0000000..b75119f --- /dev/null +++ b/android/plans/260429-1511-loto-upstream-sync/phase-06-tests-and-verification.md @@ -0,0 +1,114 @@ +# Phase 06 — Tests + verification + +## Context + +Land tests for phases 01-04 (and 05 if adopted). Port the upstream test +fixture deltas where applicable. Verify build + smoke test on a device. + +## Priority + +P0 — gate before commit. + +## Status + +pending. + +## Related code files + +- `app/src/test/java/com/miti99/loto/state/PlayerBoardViewModelTest.kt` +- `app/src/test/java/com/miti99/loto/state/AutoTickTest.kt` (only if + phase-05 adopted) + +## Implementation steps + +1. **PlayerBoardViewModelTest — confetti tier rule (phase 01)** + + Add cases: + + - 1st bingo, no other waiting row → `bingoEvent.tier == 1` + - 1st bingo, another row in Chờ → `bingoEvent.tier == 2` + - 2nd bingo, regardless → `bingoEvent.tier == 2` + - Update existing test that asserted "tier=2 needs ≥3 bingos" — that + contract is gone. Replace with the 2nd-bingo case. + + Sketch: + + ```kotlin + @Test + fun `tier-2 confetti fires on 1st bingo when another row is in Cho`() = runTest { + // craft a grid where rowA has 1 number remaining and rowB has 1 + // number remaining; cross rowA's last cell → first bingo + // expect: state.bingoEvent.tier == 2 + } + ``` + +2. **PlayerBoardViewModelTest — section Chờ ring (phase 02)** + + Add cases: + + - No waiting row → `sectionHasWaiting == [false, false, false]` + - Row 4 has exactly 1 uncrossed → `sectionHasWaiting[1] == true` + (section 1 covers rows 3..5) + - Bingo on row 4 → `sectionHasWaiting[1]` flips back to false (because + row is now complete, not waiting) + +3. **VoiceTogglesTest** (compose UI test, phase 04) + + Optional but cheap. Skip if test infra not yet set up — manual smoke + in §5 covers it. + +4. **Phase 05 (if adopted)** + + Port the 8 cases from `auto-tick.test.js`: + - NEW event flips matching cell + - Dedup: same event id no-op + - Mode=master: advances id, no flip + - Mode=player: advances id, no flip + - Off-board number: advances id, no flip + - Null grid: advances id (event consumed), no flip + - Null event: no-op + - Re-cross after manual untick: a fresh event-id flips the cell again + +5. **Manual smoke (cannot run from here, requires device)** + + ```bash + ./gradlew :app:lint :app:test :app:assembleDebug + ./gradlew :app:installDebug + ``` + + Golden path: + - Generate grid → mark 4 of 5 cells in row 0 → section 0 label glows amber + - Mark the 5th → bingo modal + confetti (tier 1 since no other row in Chờ) + - Mark 4/5 of row 4 → section 1 glows + - Bingo row 1 → tier-2 confetti (1st bingo + Chờ on row 4) + - Settings → switch mode to "Cả hai" → master toggle hint reads "+ + báo Chờ/Kinh"; switch to "Quản trò" → hint drops the "+ báo Chờ/Kinh" + - Master panel "Ván mới" button is solid amber, not gradient + - Master ≤49 number tokens render sky-blue (light) / sky-400 (dark) + - Light mode background reads as cream, not pure white + +## Todo + +- [ ] PlayerBoardViewModelTest: 3 new tier cases, update old assertions +- [ ] PlayerBoardViewModelTest: 3 sectionHasWaiting cases +- [ ] (Phase-05) AutoTickTest: 8 cases +- [ ] `./gradlew :app:test` clean +- [ ] `./gradlew :app:assembleDebug` clean +- [ ] Manual smoke per checklist +- [ ] Update `plans/todo.md` once verified + +## Success criteria + +- `./gradlew :app:test` exits 0 +- `./gradlew :app:lint :app:assembleDebug` exits 0 +- All bullets in §5 manual smoke pass + +## Risks + +- Some tier-rule tests existed under the old `>=3` contract; they will + fail when the rule flips. This is intended — replace, don't preserve. + +## Next + +Commit per phase, push branch, open PR (or land directly if working on +main per project convention). diff --git a/android/plans/260429-1511-loto-upstream-sync/plan.md b/android/plans/260429-1511-loto-upstream-sync/plan.md new file mode 100644 index 0000000..465985a --- /dev/null +++ b/android/plans/260429-1511-loto-upstream-sync/plan.md @@ -0,0 +1,59 @@ +# Loto upstream sync — 2026-04-29 + +Pull 6 logic/UX changes from `tiennm99/loto` (web) into the Android port. +Behavior-faithful where the platform allows; idiomatic Compose where it +matters. + +## Source + +- Repo: `tiennm99/loto` @ `2fb35f2` (2026-04-29 14:53 +0700) +- Port baseline: Android `fe52232` (covers upstream through `ad6291e`) +- Drift commits considered: 15 — see recon report + +## Mode + +`--port` (default). No transplant of Svelte/CSS; reproduce the visual and +behavioral outcomes in Compose. + +## Phases + +| # | Phase | Status | Risk | Files (delta) | +|----|------------------------------------|---------|------|---------------| +| 01 | Confetti & celebration tier rule | pending | low | 2 | +| 02 | Section "Chờ" ring on label band | pending | low | 3 | +| 03 | Solid-color palette refactor | pending | low | 8 | +| 04 | Per-mode voice-master hint copy | pending | low | 3 | +| 05 | (Optional) Extract auto-tick helper| pending | low | 3 | +| 06 | Tests + verification | pending | low | 2 | + +Phase ordering: 01 → 02 → 03 → 04 → (05) → 06. Phases 01-04 are largely +independent; 06 lands tests for all of them at once. + +## Out of scope (intentionally skipped) + +- Settings modal sticky title/footer — `ModalBottomSheet` is the native + equivalent; problem already solved. +- Mode picker "Cả hai" glyph — Android picker is text-only segments. +- PWA cache eviction, CSP, npm vuln overrides — web-only. + +## Reports + +- Recon: `reports/xia-recon-260429-1511.md` + +## Rollback + +Each phase ships its own commit. Revert order = 06 → 05 → 04 → 03 → 02 → 01. +No data-shape changes, no DataStore migration, no audio-asset changes — +revert is `git revert ` per phase. + +## Decisions + +- New constant `BrandSky*` colors added (replacing `BrandPink*` in master + ≤49 path). Old `BrandPink*` and `BrandIndigo*` are removed once unused. +- Wordmark gradient kept — it's the brand's only intentional gradient. +- Section Chờ ring: solid amber band + 1.5 s pulse via Compose `infiniteTransition`. + Skip animation if `LocalAccessibilityManager.areAnimationsEnabled() == false`. + +## Hand-off + +Once approved, run: `/ck:cook plans/260429-1511-loto-upstream-sync/plan.md` diff --git a/android/plans/260429-1511-loto-upstream-sync/reports/xia-recon-260429-1511.md b/android/plans/260429-1511-loto-upstream-sync/reports/xia-recon-260429-1511.md new file mode 100644 index 0000000..7d33077 --- /dev/null +++ b/android/plans/260429-1511-loto-upstream-sync/reports/xia-recon-260429-1511.md @@ -0,0 +1,143 @@ +# Xia Recon — Upstream `tiennm99/loto` → Android port sync + +## Source manifest + +- Repo: `tiennm99/loto` (SvelteKit web app, Svelte 5 + Vite 7) +- Branch: `main` +- Local clone: `/config/workspace/tiennm99/loto` +- Latest commit at recon: `2fb35f2` (2026-04-29 14:53 +0700) +- Port baseline: Android `fe52232` (2026-04-27 19:33 +0700) — bundles upstream + through `ad6291e` (2026-04-27 18:57) +- Window of drift: 2026-04-27 19:33 → 2026-04-29 14:53 (≈ 44 h, 15 commits) + +## Source map (logic-relevant) + +- `src/lib/game-logic.js` — pure card gen, persistence, row helpers +- `src/lib/auto-tick.js` — pure helper for master→player auto-tick (NEW since + baseline) +- `src/lib/call-bus.svelte.js` — one-slot bus +- `src/lib/settings-store.svelte.js` — settings + DOM apply +- `src/lib/voice.js` — bundled-MP3 playback +- `src/lib/vietnamese-number.js` — number-to-words +- `src/lib/PlayerBoard.svelte` — player UI + bingo/chờ state +- `src/lib/MasterPanel.svelte` — host UI +- `src/lib/SettingsButton.svelte` — settings modal +- `src/app.css` — palette, animations, decorative borders + +## Local map (Android) + +- `game/GameLogic.kt`, `game/VietnameseNumber.kt` — pure logic +- `state/CallBus.kt`, `state/PlayerBoardViewModel.kt`, + `state/MasterPanelViewModel.kt` — coroutine state +- `audio/VoicePlayer.kt`, `audio/VoiceManifest.kt` +- `settings/SettingsRepository.kt`, `settings/SettingsState.kt` +- `ui/board/*`, `ui/master/*`, `ui/settings/*`, `ui/theme/*` + +## Drift analysis + +Commits on/after port baseline (15 total). Filtered to logic/UX-relevant: + +| Commit | Subject | Layer | Port? | +|---------|---------------------------------------------|-------------|-------| +| 2fb35f2 | drop gradients, solid-color palette | UI | YES | +| a60ea08 | persistent Chờ ring + livelier confetti | UI + tier | YES | +| 2b74b2e | "Cả hai" glyph + sticky title/footer | settings UI | PART | +| 3e6cb90 | extract auto-tick into pure helper + tests | refactor | OPT | +| ee71bf0 | per-mode voice hint copy (+ PWA fixes) | copy | YES | +| f7db20c | UI polish v2 + PWA | UI + web | DONE | +| 9f24b6d | PWA cache eviction tighten | web only | NO | +| 7c893aa | CSP sha256 hash | web only | NO | +| 558d0c7 | inline-script guard | web only | NO | +| d94294d | npm vuln overrides | web only | NO | +| chore(plans) ×4 | plan docs | - | NO | + +## Behavior delta (the part that matters) + +### 1. Confetti tier rule (PlayerBoardViewModel.kt:166) + +- **Was**: `tier = 2 if celebratedRows.size >= 3 else 1` +- **Now**: `tier = 2 if celebratedRows.size >= 2 OR + (celebratedRows.size >= 1 AND any other row in Chờ) else 1` +- Reason: 9-row card rarely hits 3 bingos; old threshold under-celebrated wins. + +### 2. Confetti emoji + size + +- Add `🥢 🎋 🏮` to set; per-piece size jitter 1.5–2.4rem (sp). + +### 3. Section Chờ ring + +- New `sectionHasWaiting[3]` derived flag — true iff any of its 3 rows is + one cell from bingo and not yet completed. Section label gets persistent + amber ring/glow (animation respects reduced-motion). + +### 4. Per-mode voice-master hint + +- Mode = `both` → hint: "Đọc số đã xổ + báo Chờ/Kinh." +- Mode = `master`→ hint: "Đọc số đã xổ." +- Currently Android has no hint at all under the master-voice toggle. + +### 5. Solid-color palette + +- Drop every gradient (Wordmark stays — it's the only intentional brand + gradient). Use solid: rose-600 (player+UI), amber-600 (host), + emerald-600 (draw), sky-600 (master ≤49). +- Replaces master ≤49 pink → sky. +- Player accent: indigo → rose. +- "Ván mới" amber→rose gradient → solid amber-600. +- Background warmer cream `#fffbeb` (light only). + +### 6. Auto-tick pure helper (optional) + +- Upstream extracted `processAutoTick({grid, crossed, lastDraw, + lastHandledAt, mode})` into `src/lib/auto-tick.js`. +- Android `PlayerBoardViewModel.consumeBusEvent` already implements the + same dedup-by-id invariant. Optional refactor for unit-testability + parity. + +### 7. Settings sticky title/footer + +- Web fix for iPhone SE viewport (375×667). +- Android: `ModalBottomSheet` already gives system-pinned drag handle + + scroll behavior — same problem already solved by the platform. + **Skip.** + +### 8. "Cả hai" glyph + +- Web mode picker has SVG glyphs; Android `ModePicker` is text-only + (`SegmentedButton` + `Text`). **Skip.** + +## Decision matrix + +| Decision | Source's way | Local equivalent | Recommendation | +|------------------------|-----------------------------|----------------------------|----------------| +| Confetti tier | 2nd OR (1st + Chờ) | 3+ | Port (P0 — game feel) | +| Confetti emoji+size | + hội-chợ + jitter | base set, fixed 20.sp | Port (P1) | +| Section Chờ ring | amber pulsing band | - | Port (P1) | +| Voice-master hint copy | per-mode | (no hint shown) | Port (P2) | +| Solid-color palette | rose/amber/emerald/sky | rose/amber/emerald/pink+gr | Port (P1) | +| Auto-tick helper | extracted pure fn | inline in VM (semantics ✓) | Optional (P3) | +| Sticky modal bands | sticky CSS | ModalBottomSheet (native) | Skip | +| "Cả hai" glyph | inline SVG | text-only segment | Skip | + +## Risk score + +**Low.** No data-shape changes; no migration. All ports are additive (new +state field, new strings, new colors) or threshold tweaks. Tests cover the +existing tier rule — must update fixtures to the new threshold. No risk to +DataStore schema, audio assets, or audio-call coordination. + +## Approach + +`--port` mode: rewrite idiomatically for Compose/Kotlin. No transplanting +Svelte/CSS — the visual outcomes get expressed in Compose primitives. + +## Open questions + +- Should the Confetti emoji set still ship with `🥢 🎋 🏮` on devices where + the system font lacks coverage? Android default fonts since API 24 cover + these. **No action**, but revisit if QA reports tofu glyphs on older OEM + builds. +- The amber pulsing ring uses `prefers-reduced-motion`. Compose has no + direct equivalent; recommend gating on `LocalAccessibilityManager.current` + reduce-motion or simply using a static glow (no animation) — defer to + phase-02 implementation choice. diff --git a/android/plans/reports/code-reviewer-260429-1554-upstream-sync.md b/android/plans/reports/code-reviewer-260429-1554-upstream-sync.md new file mode 100644 index 0000000..c94896c --- /dev/null +++ b/android/plans/reports/code-reviewer-260429-1554-upstream-sync.md @@ -0,0 +1,149 @@ +# Code review — upstream sync (6 changes) + +**Plan:** plans/260429-1511-loto-upstream-sync/plan.md +**Recon:** plans/260429-1511-loto-upstream-sync/reports/xia-recon-260429-1511.md +**Scope:** 14 files, +215/-89 LOC. Correctness of 6 ported changes. +**Verdict:** **Approve** — no critical/major issues. 4 minors + 2 nits below. + +## Critical +None. + +## Major +None. + +## Minor + +### M1. Stale comment on tier-2 confetti gate +- **File:** app/src/main/java/com/miti99/loto/ui/board/PlayerBoardScreen.kt:160 +- Comment reads `// Confetti for tier-2 bingo (≥ 3 rows complete)`. New rule is + "≥2 OR (≥1 + Chờ)". Update to: `// Confetti for tier-2 bingo (2nd+ row, OR 1st row while another is in Chờ)`. +- Risk: future reader may revert to old threshold thinking comment is the spec. + +### M2. Dead 0.dp Spacer +- **File:** app/src/main/java/com/miti99/loto/ui/master/MasterControls.kt:70 +- `Spacer(Modifier.width(0.dp))` is a no-op — parent `Row` already has + `Arrangement.spacedBy(8.dp)` (line 51). Drop the Spacer + its import isn't + actually unused (Spacer survives through dead code), so this is purely cosmetic. + +### M3. CalledCellCream fill = BackgroundCream — zero contrast +- **Files:** app/src/main/java/com/miti99/loto/ui/theme/Color.kt:40 (`BackgroundCream = #FFFBEB`) + vs. line 53 (`CalledCellCream = #FFFBEB`). +- Light-theme page background and called-cell fill are now the same hex. The + colored ring is the only thing that separates cells from the page. Master + grid cells (tightly packed) read fine; the 160dp `CurrentNumberHero` circle + now visually "floats" — only the ring outlines it. Matches upstream, but + flag for design review. +- Suggested: either nudge `CalledCellCream` to amber-100 `#FEF3C7` (you + already have `CalledCellCreamDark` at that value but never use it), or + leave with explicit comment that this is intentional flat design. + +### M4. PlayerBoardGrid pulse runs continuously even when no section is waiting +- **File:** app/src/main/java/com/miti99/loto/ui/board/PlayerBoardGrid.kt:60-69 +- `rememberInfiniteTransition` + `animateFloat` always tick. When all three + sections have `isWaiting == false`, `pulseAlpha` is never read inside the + Modifier branch (the `else Modifier` path doesn't touch it), so no + recomposition is triggered — confirmed safe. +- Non-issue for jank: animation cost is negligible and doesn't drive + recompose. Optional optimization: gate the entire transition behind + `if (sectionHasWaiting.any { it })` to also kill the value-update job + when nothing waits, but YAGNI for a card with 9 rows. + +## Nits + +### N1. `CalledCellCreamDark` is declared but never referenced +- **File:** app/src/main/java/com/miti99/loto/ui/theme/Color.kt:54 +- Either wire it into a dark-theme branch on the called-cell fill (parallel + to the Sky400/Sky600 split) or delete it. Currently dead. + +### N2. `BackgroundLight` (#FFFFFF) still referenced as `onPrimary/onSecondary/onTertiary` +- **File:** app/src/main/java/com/miti99/loto/ui/theme/Theme.kt:11,13,15 +- Theme.kt swapped `background` → `BackgroundCream` but `onPrimary` etc. + still point to `BackgroundLight` (= white). Intentional — these are + contrast-on-color tokens, not the page color. Just confirming this isn't + an oversight; it reads correctly. + +## Verification of the 7 review questions + +### 1. Tier rule correctness — Pass +Traced `applyCrossedChange` (PlayerBoardViewModel.kt:163-186) against +`GameLogic.getWaitingNumber` (GameLogic.kt:70-83): + +- (a) **1st bingo + Chờ on another row** → `celebrated.size == 1`, + `hasActiveCho == true` (other row's `getWaitingNumber` is unaffected by + marking the just-completed row's last cell) → **tier 2**. ✓ +- (b) **2nd bingo regardless** → `celebrated.size >= 2` short-circuits + before `hasActiveCho` check → **tier 2**. ✓ +- (c) **1st bingo, no other Chờ** → `celebrated.size == 1`, + `hasActiveCho == false` → **tier 1**. ✓ +- **Just-completed row not counted in `hasActiveCho`:** loop excludes + `otherRow !in celebrated` (line 173), and even without the guard, + `getWaitingNumber` returns null when 0 cells remain (GameLogic.kt:75-82 + — `remaining` stays null). ✓ +- **`crossedArr` is `newCrossed`** (line 152), so the just-marked cell + is reflected. ✓ + +Pre-existing edge case (not introduced by this PR): if a single draw +completes 2 rows simultaneously (two rows have the same waiting number), +Pass-1 only celebrates one per call. The 2nd row stays uncelebrated until +the next state change. This matches upstream Svelte semantics — flagging +only because it was on my radar. + +### 2. Race / leak risk on shared infiniteTransition — No issue +The transition runs continuously but `pulseAlpha` is only read inside the +`if (isWaiting)` Modifier branch, so no useless recompose. Animation jobs +are owned by composition scope and torn down with the screen — no leak. + +### 3. `isSystemInDarkTheme()` inside @Composable — All correct +- MasterCell.kt:57 — inside @Composable `MasterCell`. ✓ +- CalledHistory.kt:74 — inside @Composable `CalledChip` (private). ✓ +- CurrentNumberHero.kt:46 — inside @Composable `CurrentNumberHero`. ✓ + +### 4. Light/dark contrast on amber-cream cell — Adequate, see M3 +Light theme: BackgroundCream `#FFFBEB` + Sky600 `#0284C7` ring → 6.1:1 +contrast — passes WCAG AA. Sky600 text on CalledCellCream same value — +6.1:1 — fine. Emerald500 `#10B981` on cream — 2.1:1 — borderline for +small text but fine for the ring color (decorative). M3 flags the +fill==background issue separately. + +### 5. VoiceToggles gating — Matches recon spec +VoiceToggles.kt:35 (`mode != PLAYER`) and :53 (`mode != MASTER`) hide the +master row in PLAYER-only mode and the player rows in MASTER-only mode. +Hint copy switches BOTH vs MASTER on line 41-44. Matches plan +phase-04-voice-hint-per-mode and recon §4. + +### 6. Stale imports — None found +Grepped each modified file: +- KinhModal.kt: all 33 imports used. No stale `Brush` / gradient imports. +- MasterControls.kt: all 25 imports used. No stale gradient imports. + `BrandEmeraldLight` still used by `AutoCallChip` (line 111, 118). +- PlayerBoardGrid.kt: all imports used. +- PlayerBoardScreen.kt: all imports used. +- Master compose files: imports for `BrandSky400/600` and `isSystemInDarkTheme` + all referenced. +- Color.kt: comment references "former pink" — fine; no `BrandPinkX` / + `BrandIndigoX` left in any source file (verified with grep). + +### 7. Behavior preservation — One tier consumer, OK +`grep -rn "bingoEvent.tier"` returns a single hit: +PlayerBoardScreen.kt:161 `state.bingoEvent?.tier == 2`. The new rule +still uses `tier ∈ {1, 2}`, so the existing "tier == 2 ⇒ confetti" +conditional fires at a *higher* rate than before (intended — see recon +§1). No type/shape break. + +## Summary + +The 6 ports are clean, the tier rule is correctly threaded through +`applyCrossedChange`, the Section Chờ ring uses a shared transition +without leaks, and the gating in `VoiceToggles` matches the upstream +spec. Theme cleanup is consistent (no leftover `BrandPink/Indigo`). +Recommended: **approve and ship**, optionally fold M1 (1-line comment) +and M2 (drop dead Spacer) into the same commit. M3/M4 are observations +for the design/perf docket, not blockers. + +## Unresolved questions +- M3: Is `CalledCellCream == BackgroundCream` (both `#FFFBEB`) + intentional flat design, or should the called-cell fill nudge to + amber-100 `#FEF3C7` for contrast? Upstream parity suggests intentional; + worth a 1-line confirmation. +- N1: Should `CalledCellCreamDark` be wired into a dark-theme branch + for the called-cell fill (parallel to Sky400/Sky600), or deleted?