mirror of
https://github.com/tiennm99/loto.git
synced 2026-10-11 03:13:40 +00:00
chore(plans): record 260429-1511 upstream-sync plan + reports
Plan directory and recon/code-review reports for the 6-commit
upstream sync from tiennm99/loto @ 2fb35f2.
This commit is contained in:
1 parent
593c270211
commit
e80087c339
9 files changed
+1114
No files matched your search
@@ -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.
|
||||
@@ -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<Boolean> = 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<Boolean>`.
|
||||
- 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).
|
||||
@@ -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).
|
||||
@@ -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
|
||||
<!-- Voice-master hints, per mode (phase 04) -->
|
||||
<string name="voice_master_hint_both">Đọc số đã xổ + báo Chờ/Kinh.</string>
|
||||
<string name="voice_master_hint_master_only">Đọc số đã xổ.</string>
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -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<List<Boolean>>,
|
||||
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<List<Int>>?,
|
||||
crossed: List<List<Boolean>>,
|
||||
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).
|
||||
@@ -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).
|
||||
@@ -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 <sha>` 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`
|
||||
@@ -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.
|
||||
@@ -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?
|
||||
Reference in new issue
Block a user