fix(budget): note — auto-focus/select seulement si vide #3
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "fix/note-autofocus"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #1
FocusRequester+LaunchedEffect(initialNote.isEmpty())+delay(500)avantrequestFocus()+SelectAll(TextRange) +keyboardController.show()TextFieldValuepour contrôle de la sélection,modifier.focusRequester(focusRequester)surOutlinedTextFieldStore.setMonthNote+persist()inchangé,remember(monthKey)conserve le switch de moisTest:
./scripts/deploy-wifi.sh staging→BUILD SUCCESSFUL,pid 3141vivant,AndroidRuntime:Evide, manuel vide→focus / avec texte→pas de clavier OKAudit PR #3 —
fix(budget): note — auto-focus/select seulement si videBranche:
fix/note-autofocus(66328d4) →main(d419960) · Scope:BudgetScreen.kt· +25 / -7Méthode:
workflowz— 6 dimensions parallèles (compose-lifecycle|state-persist|ux-a11y-m3|correctness-edge|code-quality|perf-security) → 26 findings bruts → vérification adversarial (3 votes,default = REFUTED si doute) → 18 confirmés, 8 réfutés✅ Ce qui est bien fait
initialNote.isEmpty() → requestFocus + SelectAll + keyboard.show(), sinon pas de focus. Test manuel du PR (stagingvide → focus OK / avec texte → pas de clavier) reproduit.TextFieldValue+TextRangemaîtrisé,focusRequesterbranché,Store.setMonthNoteinchangé.persist()(sauvegarde surEnregistrer+onDismissidentique, même si ce 2ᵉ point est le bug #1 ci-dessous).📊 Récap — 18 confirmés (high confidence)
file:lineonDismissRequestpersiste =Enregistrerimplicite (swipe/scrim/back = save)BudgetScreen.kt:250buildJsonStringn’écrit quebudgets.keysStore.kt:740isBlankvsisEmpty— whitespace fantômeStore.kt:836vsBudgetScreen.kt:239,390monthKeyswitch pendant sheet ouverte → draft jeté + save sur mauvaise cléBudgetScreen.kt:231-253LaunchedEffect(Boolean)écrase la frappe +delay(500)écraseselectionBudgetScreen.kt:239-245FocusRequesterremember{}sans clé → instance stale vsnoteField: remember(monthKey)BudgetScreen.kt:237noteFieldnon-Saveable— rotation / process death = draft perduBudgetScreen.kt:234noteField+ doubleTextRangeBudgetScreen.kt:234-245delay(500)magique vssheetState.isVisible+catch(Exception)largeBudgetScreen.kt:241-244TextFieldValuenon observé (remember(monthKey){ Store.monthNotes[...]}figé) +trimmanquantBudgetScreen.kt:233persistasyncsingleThreadExecutor→ kill = perte +exportJsonstaleStore.kt:736,814minLines=3sansmaxLines/verticalScroll— CTA poussé hors viewportBudgetScreen.kt:263Store.setMonthNote×2, magic 500,skipPartiallyExpandedmanquantBudgetScreen.kt:232,250,272TextFieldValueà chaque recompositionBudgetScreen.kt:233heading(), label génériqueNote, pas d’imeActionBudgetScreen.kt:256-267TextFieldValue.Savernon utilisé,heading/imeActionmanquants8 réfutés (non bloquants) :
LaunchedEffectleak si sheet non composée, double-persist concurrent, perfderivedStateOfprématurée.🔍 Détails P0 — bloquants
F01 —
onDismissRequest= save (M3 violation) —BudgetScreen.kt:250ModalBottomSheetappelleonDismissRequestsur scrim tap, swipe-down, back. Ici tout dismiss persiste. Spec lot E/F attenddismiss = cancel,save = explicite. Aucundirtycheck.Impact: impossible d’annuler une saisie accidentelle ; effacement par espaces
" "supprime la note précédente sans consentement.Fix P0:
F02 — Note orpheline perdue —
Store.kt:740(préexistant, CRITIQUE)Repro: mois futur sans assignation →
setMonthNote("2026-09","idée")→ restart → note disparue.loadreconstruitnewMonthNotesuniquement en parcourantmonths.keys()(700).Fix P0:
F03 —
isBlankvsisEmpty— fantôme whitespaceStore.setMonthNote(836):if(note.isBlank()) remove→" "= deleteBudgetScreen.LaunchedEffect(239):if(initialNote.isEmpty())→" "= non-vide → pas de focusStore.monthNotes[monthKey].isNullOrEmpty()→" "= existe (colored)→ note invisible mais icône colorée, supprimée seulement au prochain save.
Fix P0: normaliser partout en
isBlank+ trim à l’entrée :F04 —
monthKeyswitch sheet ouverte → corruption inter-moisinitialNote = remember(monthKey){ Store.monthNotes[monthKey]?: "" }etnoteField = remember(monthKey){ TextFieldValue(initialNote) }.Si
showMonthNote==true,MonthNavigator/ReadyToAssignHerorestent composés sous le scrim et peuvent changermonthKey(swipe). Le contenu du sheet est réinitialisé live, draft du mois A jeté, puisonDismiss/persistécrit sur mois B (clé capturée à l’exécution).Fix P0: geler la clé à l’ouverture ou bloquer la nav :
F05 —
LaunchedEffect(initialNote.isEmpty())+delay(500)écraseBoolean→ vide→vide (true→true) et plein→plein (false→false) ne relance pas. Si navigation inter-mois vide était possible (F04), 2ᵉ mois vide n’aurait pas de focus.noteFieldinitTextRange(initialNote.length)(curseur fin) puis après 500msnoteField.copy(selection=TextRange(0, text.length))(selectAll). Si l’utilisateur tape dans les 500ms, son curseur est écrasé → sélection surprise.delay(500)non corrélé à l’animModalBottomSheet(300-400ms variable).catch(_:Exception)masqueIllegalStateExceptionsifocusRequesterpas encore attaché.Fix P0:
F06 —
FocusRequestersans clé (stale)Voir fix F05 :
remember(monthKey){ FocusRequester() }.F07 —
noteFieldnon-SaveableVoir fix F05 :
rememberSaveable(..., TextFieldValue.Saver). Aujourd’huimonthKey/showMonthNotesontrememberSaveable,noteFieldest volatil → rotation = draft perdu alors que le sheet se rouvre.🟠 P1 — importants
F09 delay vs sheetState → cf fix F05 (supprimer
delay, attendreisVisible).F10 TextFieldValue stale →
remember(monthKey){ Store.monthNotes[...]}ne s’abonne pas aux mutationsStore.monthNotes(mutableState) siimportJson/loadarrive sheet ouverte. Soit observerStore.monthNotes[monthKey]sansremember, soitLaunchedEffect(Store.monthNotes[monthKey])resync si!dirty.F11 persist async → préexistant.
exportJson()lit le fichier en retard (queue non drainée). Au minimumbuildJsonString()direct pour l’export, etpersist()synchrone ouwithContext(IO) + fsyncavant kill. Pas bloquant pour ce PR mais documenté.F12 CTA hors viewport →
OutlinedTextField(minLines=3)sansmaxLines: note longue pousseEnregistrersous le pli (pas deverticalScrollsur leColumndu sheet, contrairement àCategoryDialogetCoverSheet). Fix:maxLines=6+Modifier.verticalScroll.F13 duplication → factoriser
saveNote()et sortir le magic500.🟡 P2 — polish / a11y
Text("Note — ${month}")sanssemantics{heading()};OutlinedTextField(label={Text("Note")})générique →label="Note — ${capitalize(month)}"+KeyboardOptions(imeAction=Done, onDone={persistAndClose()}).sheetStatesansskipPartiallyExpanded=true→ état intermédiaire.FocusRequester,TextRange,delaysi supprimé).✅ Repro & vérif attendus après fix
" "→ Enregistrer → supprimée (pas de fantôme), icône redevientonSurfaceVariant.🎯 Correctifs priorisés
P0 merge-block: F01, F02, F03, F04, F05, F06, F07, F08 → à corriger dans cette PR.
P1 recommandé dans cette PR: F09, F10, F13
P2 follow-up OK: F11, F12, F14-F15
Checklist re-review :
rememberSaveable(sheetMonthKey, TextFieldValue.Saver)+FocusRequester(sheetMonthKey)+LaunchedEffect(sheetState.isVisible)onDismissne persiste plus ;trim+isBlankunifié ; unionbudgets+monthNotesen persistAndroidRuntime:EvideAudit généré via
workflowzmulti-agents (6 dimensions, 18/26 confirmés adversarial). F02/F11 préexistants signalés car exposés par la feature. Prochaine passe après patch P0.Suite PR — audit appliqué (
a5584e2)Tous les correctifs de l'audit #10 appliqués et pushés sur
fix/note-autofocus(66328d4→a5584e2, +65 / -32).Sub-agents workflowz :
BudgetFix→BudgetScreen.kt(F01,F04-F09,F12,F13,F15)StoreFix→Store.kt(F02,F03)Verify→ 7 checks adversarialhigh confidence→ 7/7is_fixed:trueChangements clés
BudgetScreen:260onDismissRequest = { showMonthNote=false }(cancel), save uniquement surEnregistrer/DoneavecsheetMonthKeyStore:740allMonthKeys = budgets.keys + monthNotes.keys+(budgets[key]?:emptyMap())Store:709,837isNotBlank+trim()àloadetsetMonthNote(whitespace fantôme éliminé)BudgetScreen:238sheetMonthKey = remember { monthKey }gelé, plus aucunmonthKeylive aprèsBudgetScreen:251LaunchedEffect(sheetState, sheetMonthKey)+snapshotFlow{isVisible}.first{it}+delay(80)au lieu dedelay(500)+catch IllegalStateExceptionseulBudgetScreen:242,248rememberSaveable(sheetMonthKey, saver=TextFieldValue.Saver)+remember(sheetMonthKey){FocusRequester()}BudgetScreen:244TextRange(0,length)si blank sinonTextRange(length)— plus decopypost-delayBudgetScreen:272-300skipPartiallyExpanded=true,verticalScroll,maxLines=6, label mois-spécifique,heading()+imeAction Done,Row Annuler/EnregistrerRepro checklist re-validé (manuel attendu)
isVisible— plus de latence magiquerememberSaveable)" "→Enregistrersupprime (pas de fantôme)git pushOK — PR #3 heada5584e2prête pour re-review."## Audit PR #3 — HEAD
a5584e2(post-fix)\n\n### Verdict global\n\nREQUEST_CHANGES — Les P0/P1 initiaux (F01–F13) sont corrigés et tiennent en revue statique, mais l'audit révèle 3 HIGH + 1 MEDIUM dont 1 régression émergente (F04-bis) directement liée au gel desheetMonthKey. Le filet de sécurité ajouté dansa5584e2(rememberSaveable + FocusRequester keyed + snapshotFlow) crée une nouvelle classe de bugs : le sheet est désormais correctement isolé du recompose, mais il dérive silencieusement duMonthNavigatorqu'il surplombe.\n\nÀ bloquer avant merge : H1 (data loss silencieux), H2 (CTA inaccessible), H3 (corruption JSON possible). M1 = robuste mais peut supprimer des caractères en saisie rapide.\n\n---\n\n### Ce qui a été corrigé (succinct)\n\n| ID | Fichier:Ligne | Statut |\n|----|---------------|--------|\n| F01 dismiss=cancel | BudgetScreen.kt:273, 301 | ✅onDismissRequest = { showMonthNote = false },Annulersans save |\n| F02 rememberSaveable TextFieldValue | BudgetScreen.kt:244 | ✅rememberSaveable(sheetMonthKey, stateSaver = TextFieldValue.Saver)|\n| F03 FocusRequester keyed | BudgetScreen.kt:250 | ✅remember(sheetMonthKey) { FocusRequester() }|\n| F04 sheetMonthKey gelé | BudgetScreen.kt:238 | ⚠️ Partiel — corrige le recompose, ouvre F04-bis (cf. H1) |\n| F05 LaunchedEffect sur isVisible | BudgetScreen.kt:253-266 | ✅snapshotFlow { sheetState.isVisible }.first { it }|\n| F06 trim+isBlank unifié | Store.kt (load + setMonthNote) | ✅trim()+isBlank()des deux côtés |\n| F07 buildJsonString union | Store.kt:738-755 (≈) | ✅budgets.keys ∪ monthNotes.keys|\n| F08 orpheline préservée | Store.kt setMonthNote | ✅ pas de drop si budgets.keys ∅ pour ce mois |\n| F09 maxLines + verticalScroll | BudgetScreen.kt:276-279, 295-296 | ✅maxLines = 6+verticalScroll(rememberScrollState())|\n| F10 Row Annuler/Enregistrer | BudgetScreen.kt:299-311 | ✅ Row pondéré weight(1f) |\n| F11 heading semantics | BudgetScreen.kt:283 | ✅Modifier.semantics { heading() }|\n| F12 label mois-spécifique | BudgetScreen.kt:240 | ✅\"Note — ${capitalize(...)}\"|\n| F13 imeAction Done | BudgetScreen.kt:297-298 | ✅KeyboardActions(onDone = { saveAndClose() })|\n| skipPartiallyExpanded | BudgetScreen.kt:241 | ✅rememberModalBottomSheetState(skipPartiallyExpanded = true)|\n\n---\n\n### Nouveaux findings (4)\n\n#### 🔴 H1 — F04-bis : desyncModalBottomSheet↔MonthNavigator(data loss silencieux)\n\n- Lieu :BudgetScreen.kt:238+MonthNavigatorswipe/chevron (lignes ≈327-425)\n- Description :sheetMonthKey = rememberSaveable { monthKey }capture la valeur à l'ouverture du sheet.ModalBottomSheetMaterial3 ne bloque pas les pointer events du contenu en dessous — un swipe surMonthNavigatorou un tap sur les chevrons fait mutermonthKeycôté parent pendant que le sheet est ouvert.MonthNavigatorreste interactif.saveAndClose(ligne 267-270) écrit sursheetMonthKey(l'ancien mois capturé), pas surmonthKeycourant.\n- Repro :\n 1. Ouvrir la note du mois de mars\n 2. Swiper vers mai sur leMonthNavigator(le sheet reste ouvert)\n 3. Saisir du texte\n 4. Taper Enregistrer\n 5. → La note de mars est écrasée, celle de mai reste vide\n- Impact : Data loss silencieux. Aucun feedback utilisateur. Pas de snackbar. Pas de undo. Conforme RGPD/UX critique.\n- Suggestion (deux options, préférer A) :\n\nkotlin\n// A) Fermer le sheet quand monthKey change\nLaunchedEffect(monthKey) {\n if (sheetMonthKey != monthKey) showMonthNote = false\n}\n\n// B) Bloquer MonthNavigator tant que le sheet est ouvert\nMonthNavigator(\n enabled = !showMonthNote,\n month = month, monthKey = monthKey, ...\n)\n\n\nA est minimal et préserve le geste utilisateur ; B est plus défensif mais peut surprendre (swipe qui ne répond pas). À documenter dans un test d'intégration Compose (createAndroidComposeRule).\n\n---\n\n#### 🔴 H2 —imePadding()absent : CTA Enregistrer occlus par le clavier\n\n- Lieu :BudgetScreen.kt:276-279(Column de la sheet note)\n- Description :LaunchedEffectligne 263 forcekeyboardController.show(). LaColumna.padding(...)+.verticalScroll(...)mais pas.imePadding(). Sur écrans < 6" en portrait (ou split-screen), l'IME couvre les boutons Annuler / Enregistrer.\n- Contraste projet :TransactionsScreen.kt:1310-1314utilise déjà.imePadding().navigationBarsPadding()— pattern existant, écart de cohérence.\n- Impact : Bouton primaire inaccessible → impossible de sauvegarder sans dismisser l'IME manuellement. UX dégradée sur la majorité des appareils Android (Pixel 4a, Galaxy A-series).\n- Suggestion :\n\nkotlin\nColumn(\n Modifier\n .padding(horizontal = 24.dp, vertical = 8.dp)\n .verticalScroll(rememberScrollState())\n .imePadding() // ← ajouter\n .navigationBarsPadding() // ← ajouter (cohérence TransactionsScreen)\n)\n\n\n---\n\n#### 🔴 H3 —persist()non-atomique + executor non-daemon, jamais shut down\n\n- Lieu :Store.kt:736, 818\n- Description :\n -File(...).writeText(json)n'est pas atomique : unkill -9, OOM, ou crash pendant l'écriture produit un fichier vide ou partiel. Au prochain lancement,load()jette → seed appliqué → toutes les notes saisies perdues.\n -Executors.newSingleThreadExecutor()sansThreadFactorydaemon → le thread persiste après destruction de l'objetStore(fuail GC, bien que Store soit un object singleton). Pas deshutdown()dans aucun chemin.\n- Repro : ouvrir l'app, modifier une note,adb shell am kill com.alexis.m3ependant la sauvegarde → relancer → note absente.\n- Impact : Corruption de persistance + fuite thread mineure. Single-thread executor par instance Store = OK pour contention, mais absence d'atomicité est un risque data.\n- Suggestion :\n\nkotlin\n// ThreadFactory daemon\nprivate val persistExecutor: ExecutorService = Executors.newSingleThreadExecutor { r ->\n Thread(r, \"store-persist\").apply { isDaemon = true }\n}\n\n// Persist atomique\nprivate fun persist() {\n val json = buildJsonString()\n persistExecutor.execute {\n val target = File(c.filesDir, \"budget.json\")\n val tmp = File(c.filesDir, \"budget.json.tmp\")\n try {\n tmp.writeText(json)\n Files.move(tmp.toPath(), target.toPath(),\n StandardCopyOption.REPLACE_EXISTING,\n StandardCopyOption.ATOMIC_MOVE)\n } catch (_: Exception) {\n tmp.delete()\n }\n }\n}\n\n\nAlternative plus simple : passer à unChannel<Unit>+Flow.debounce(150).collect { persist() }et fermer viaclose()dansonCleared()si Store migre vers ViewModel. Pour l'objet singleton actuel, daemon + tmp+rename suffit.\n\n---\n\n#### 🟡 M1 —LaunchedEffectvole le focus pendant la frappe rapide\n\n- Lieu :BudgetScreen.kt:253-266\n- Description :snapshotFlow { sheetState.isVisible }.first { it }+delay(80)+requestFocus()+keyboardController.show()exécutés inconditionnellement quandisBlank. Pendant l'animation d'ouverture (80 ms), si l'utilisateur tape déjà (geste très rapide : ouvre + tape), lerequestFocuspeut détacher/reattacher la composition region IME → caractères dropped.\n- Repro : sélectionner « Note » rapidement, taper immédiatement « a » → leapeut être perdu (visible surtout sur Gboard avec prediction activée).\n- Impact : Cas marginal mais reproductible. Pas de data loss (texte vide de toute façon si blank initial), juste frustration.\n- Suggestion :\n\nkotlin\nLaunchedEffect(sheetState, sheetMonthKey, isBlank) {\n if (!isBlank) return@LaunchedEffect\n try { snapshotFlow { sheetState.isVisible }.first { it } }\n catch (e: kotlinx.coroutines.CancellationException) { throw e }\n catch (_: Exception) {}\n delay(80)\n // Skip si l'utilisateur a déjà commencé à taper\n if (noteField.text.isNotBlank()) return@LaunchedEffect\n try {\n focusRequester.requestFocus()\n keyboardController?.show()\n } catch (_: IllegalStateException) {}\n}\n\n\n---\n\n### Plan d'action\n\n#### P0 (bloquant merge)\n1. H1 — Fermer le sheet quandmonthKeychange (LaunchedEffect4 lignes). Tester avec Espresso/Compose rule :composeTestRule.onNodeWithText(\"Note — Mars\").assertExists()→ swipe →assertDoesNotExist().\n2. H2 — Ajouter.imePadding().navigationBarsPadding()sur laColumnnote. Smoke test sur Pixel 4a emulator (API 34, sw600dp height).\n3. H3 — Atomic write (tmp +Files.move ATOMIC_MOVE) + thread daemon. Test unitaireStoreTest.persist_isAtomicOnKillavecFiles.deletependant écriture simulée.\n\n#### P1 (à corriger dans la même PR)\n4. M1 — GuardisBlankdunoteFieldavantrequestFocus.\n\n#### P2 (backlog)\n-Storeen object singleton → planifier migration versViewModel+ Hilt pour bénéficier deonCleared()(shutdown propre executor, scope dédié).\n- Snapshot demonthNotes(Store.kt:821≈) : exposer unStateFlow<Map<String, String>>pour les consumers Compose, actuellement lecture directe de laMapmutable (pas de recompose trigger si modif hors sheet).\n- Test :BudgetScreenNoteSheetTestcovering open/save/cancel/dismiss + month-desync (H1).\n\n---\n\n### Repro checklist mise à jour\n\n\n[ ] H1 — Ouvrir note mars → swipe vers mai sur MonthNavigator →\n sheet reste affiché → Enregistrer → vérifier\n que Store.monthNotes[\"2026-03\"] (PAS 2026-05) est mis à jour\n[ ] H1-bis — Idem avec chevron (bouton) au lieu du swipe\n[ ] H2 — Émulateur 5.5\" portrait → ouvrir note → vérifier que\n les boutons Annuler/Enregistrer sont visibles au-dessus de l'IME\n[ ] H3 — Modifier note → adb shell am kill com.alexis.m3e →\n relancer → vérifier que la note est intacte (atomicité)\n[ ] H3-bis — Modifier note → supprimer /data/data/com.alexis.m3e/files/budget.json\n pendant la sauvegarde simulée → vérifier fichier final cohérent\n[ ] M1 — Ouvrir note → taper immédiatement → vérifier\n que le premier caractère n'est pas perdu\n[ ] F04-fix — Ouvrir note mars → rotation device → sheet\n doit se rouvrir sur mars (rememberSaveable OK déjà)\n[ ] F01-F13 — Re-run la checklist initiale (toujours verts\n après corrections H1-H3-M1)\n"