Verbomatique Junior — sweep audit and costed plan (Phase 1)
Verbomatique Junior — sweep audit and costed plan (Phase 1)
23 Sept 2026 · branch audit/junior-sweep from release/junior-readiness @ ccfbf00 · Secondaire reference: main @ a17a977 (read-only export) · no app code changed.
Screenshots are in img/. All captures came from the iOS 26.2 simulators (iPhone 17 Pro, iPhone SE 3rd gen, iPad Air 11” M3). They were driven by throwaway capture harnesses kept outside the repo, and the full-device images were taken with simctl io screenshot.
Decisions for Dries
These are the choices that shape the plan. The recommended option is listed first.
- Write-method fix. (a) Recommended: port Secondaire’s
WrittenIndicativeStudyWidgetandFocusEnabledTextFieldpattern. (b) A minimal patch: remove theUniqueKeyandautofocus, and stopadd()from notifying. (a) costs about 2 hours more and brings the animated check and haptics with it. - Keyboard behaviour for children. Turn off autocorrect, QuickType suggestions and smart quotes/dashes in every answer field. Recommended: yes. Children should see exactly what they typed.
- Look and feel. (a) Recommended: adopt Secondaire’s Material 3 theme structure (
lib/theme.dart), recoloured with a Junior palette. (b) Make Junior look identical to Secondaire. (c) A new kid-specific design. This needs a design choice: the palette, and whether Junior keeps its own mascot colours. - App structure. Replace the seven nested
MaterialApps with one app, one theme and one navigator. Recommended: yes. This is the root cause of the theme jumps, the black bands and the lost back behaviour. It is the biggest single item. - Accessibility text scale. The current cap is 1.3× everywhere. Lift the cap on answer and label text for children who use large text. Recommended: lift to at least 2.0× where the layout allows.
- Orientation and iPad. iPad is enabled today and looks stretched (see §2). (a) Recommended: keep iPad and add a centred max-width layout, as Secondaire does. (b) Drop iPad. Separately, lock iPhone to portrait, recommended.
- Content scope. Junior has présent only for 32 verbs, while Secondaire has 6 tenses. Decide whether Junior gets more tenses (a curriculum call) or stays présent-only. Fix
savoir= “kennen” → “weten” regardless. - History/streak screen. Secondaire’s streak and timeline are local-only. Its
PracticeSessionstores the typed answers, which the Junior privacy policy does not allow (“geen vrije tekst”). (a) Recommended: leave it out for launch. (b) Port it without storing answers, and updatedocs/privacy/nl-be.md. - Languages. The UI is hard-coded Dutch and only
app_en.arbexists. Move toapp_nl.arb, with nl-BE only, or add English too. - Real-device check. After Phase A, Dries tests on a real iPhone: QuickType, a hardware keyboard, dictation and Scribble (on iPad). The simulator can’t cover those.
Top findings
- The write-method focus loss is reproduced, and the cause is proven. It happens on iOS and Android. It is not a keyboard problem. The write-mode route stays mounted under the test route. Every keystroke in the test calls
VerbTestModel.add(), which callsnotifyListeners(). The hidden write-modeTextCheckeris then recreated under aUniqueKey(), and itsautofocus: truetakes focus back. After the first character, focus sits on the invisible write-mode field. See §3. - Answer fields are invisible until tapped.
CustomTheme.lightThemebuilds its colours withColorScheme.fromSwatch(), which resolvesoutlineto white (#FFFFFF). Material 3 draws the unfocusedOutlineInputBorderinoutline, so the result is a white border on a white card. This was measured with a probe test. - Seven nested
MaterialApps with two different themes. They cause:- colour and background jumps between screens (pink-tinted
ThemeData()versus whiteCustomTheme); - black bands above and below five screens (
SafeAreaoutsideScaffold); - no localizations inside the routes;
- a navigation stack that grows with hidden routes, which is what makes finding 1 possible.
- colour and background jumps between screens (pink-tinted
- Layout doesn’t adapt. Sizes are fixed (answer box 170×40, pronoun box 95×40), so “ils/elles” is truncated to “ils/ell…” on every device. The iPad shows phone-sized widgets stretched across 820 pt. Text is capped at 1.3×. There is no landscape or tablet handling and no real dark mode.
- The tests look green but miss this. 75 of 75 unit/widget tests pass and line coverage is 82%. But
flutter testnever exercises the route stack. The smoke test’sforEach((e) async* {...})blocks (integration_test/smoke_test.dart:664,684,701,736) never run, andtester.enterTextre-focuses the field on every call, which hides the focus theft. The repro test in §3 fails on today’s code.
1. Architecture side by side
| Dimension | Junior (~/AndroidStudioProjects/verbomatique) |
Secondaire (main @ a17a977) |
|---|---|---|
| State | MultiProvider in lib/main.dart:30-36 holds VerbTestModel, StudyModeState and CardinalitySelection. lib/src/app.dart:36,40 wraps the app in a second VerbTestModel, which shadows the first. The provided CardinalitySelection is never read; routes use settingsController.cardinalitySelection (lib/settings/settings_controller.dart:44). SettingsController is passed to every route by constructor. |
Same Provider pattern (lib/main.dart:133-142), plus CoverAllController, StreakEngine/PracticeSession, and the ads, consent and IAP notifiers, which must not be ported. |
| Navigation | An onGenerateRoute switch at lib/src/app.dart:63-95, with 8 routes. There are no route arguments; state travels in VerbTestModel. The code mixes popAndPushNamed, restorablePopAndPushNamed and pushNamed. The write → test step uses pushNamed (lib/routes/verb_indicative_study_write_mode_route.dart:65), so the write route stays mounted underneath. |
The same switch plus a timeline and streak route (10 routes, lib/src/app.dart:76-124). It has an explicit back button and integration_test/back_button_test.dart. |
| Theming / tokens | CustomTheme.lightTheme (lib/theme/custom_theme.dart:5-46) uses ColorScheme.fromSwatch() (outline = white), font sizes 25/20/15, indigoAccent, and deprecated MaterialStateProperty. The root app uses a plain ThemeData() (lib/src/app.dart:60). Widgets read CustomTheme.lightTheme.textTheme directly, and Theme.of(context) is used nowhere. There are no spacing tokens and no dark scheme. |
MaterialTheme (lib/theme.dart) provides full M3 ColorSchemes: light, dark, and medium/high contrast. Spacing is still literal (24/16/8). |
| Verb content | 32 verbs, présent only, all inline in lib/repository/french_repository.dart (registered at :261-325) in a HashMap. savoir is translated as “kennen” (:270), which is wrong. |
The same 32 verbs, one file each (lib/repository/verbs/verb_*.dart), with 6 tenses and nl+en translations, sorted in the picker. Content tooling is in scripts/list_verbs.py, report_forms.py and vetted_wrong_forms.json. |
| Persistence | SharedPreferences with 3 keys: themeMode, cardinality.singularEnabled, cardinality.pluralEnabled (lib/settings/settings_service.dart:18,32,39-40). These match the privacy policy. |
The same keys, plus streakEngine (JSON that includes the typed answers), notification.* and iap.adsRemoved. |
| l10n | supportedLocales is en only (lib/src/app.dart:55-57). app_en.arb has 1 key (appTitle) and there is no nl ARB. The UI is hard-coded Dutch, for example lib/main.dart:101,107,116, lib/settings/settings_view.dart:42,67,72,77, lib/routes/verb_studymode_selection_route.dart:43,51,59, and lib/routes/verb_indicative_evaluate_route.dart:118,149,169. The nested apps pass no delegates. |
6 ARB files (en, en_CA, nl, nl_BE, nl_NL), each with about 54 keys. Delegates are passed to every nested app. |
| Tests | 28 unit/widget files with 75 tests, all passing; 82.2% line coverage (lib/settings 28.8%, lib/src/app.dart 43%). One integration file, integration_test/smoke_test.dart, with 5 flows; its async* loops never run. |
61 unit/widget files, including layout tests (test/routes/verb_selection_layout_test.dart). 30 integration files, many of which are capture harnesses. |
| iOS | Deployment target 15.0 (ios/Podfile, project.pbxproj and AppFrameworkInfo.plist agree). Devices 1,2, with landscape allowed on iPhone. No ads or tracking keys. CFBundleDisplayName is “Verbomatique Junior”. |
Target 16.0. Adds GADApplicationIdentifier, NSUserTrackingUsageDescription, about 50 SKAdNetworkItems, and a scene manifest. None of these may be ported. |
| Android | minSdk = flutter.minSdkVersion, target 36. No INTERNET permission in main. Release signing reads the gitignored android/keystore.properties. There is also a stale second MainActivity.kt under kotlin/com/…. |
Adds google-services, Crashlytics, POST_NOTIFICATIONS and the AdMob app ID. None of these may be ported. |
Screen flow.
- Junior:
VerbomatiqueHomePage→VerbSelectionRoute(with a search delegate) →VerbStudymodeSelectionRoute→ cover / flashcard / write route →VerbIndicativeTestRoute→VerbIndicativeEvaluateRoute.SettingsViewis reachable from every screen. - Secondaire: splash → timeline (with New/Settings navigation bar) → four-wheel picker → cover / flashcard / write → test → streak congratulation → evaluate.
2. Layout and UX
Screen pairs (iPhone 17 Pro, Junior on the left, Secondaire on the right)
| Screen | Pair |
|---|---|
| Home / splash | ![]() |
| Verb choice | ![]() |
| Cover mode | ![]() |
| Flashcards | ![]() |
| Write mode (empty) | ![]() |
| Write mode (typed, correct) | ![]() |
| Test (start; Secondaire shows its first field already filled by the harness) | ![]() |
| Test (filled, 1 mistake) | ![]() |
Every Junior screen, per device: iPhone 17 Pro · iPhone SE · iPad Air 11” · dark mode + largest accessibility text. Secondaire: iPhone 17 Pro · iPhone SE · iPad Air 11”.
Capture caveat: on Secondaire, the “settings” and “evaluate” captures landed on the splash screen rather than the intended screen. The harness taps through the ad-free test path, so those two frames are not a fair comparison and are left out of the pairs. Junior’s “mode choice” screen has no Secondaire equivalent; Secondaire picks the mode on the wheel screen.
“Learning-curve” issues in Junior
Screenshots are in the sheets above unless a specific image is linked.
| # | Issue | Evidence (file:line) | Seen in |
|---|---|---|---|
| L1 | Answer fields invisible when unfocused. outline = #FFFFFF from ColorScheme.fromSwatch(). |
lib/theme/custom_theme.dart:43-44; fields at lib/routes/verb_indicative_test_route.dart:179-190 and lib/widgets/text_checker.dart:61-71 |
test empty, write mode back |
| L2 | Black bands top and bottom. SafeArea wraps the Scaffold instead of its body. |
lib/routes/verb_selection_route.dart:49, verb_studymode_selection_route.dart:31, verb_indicative_study_cover_mode_route.dart:36, verb_indicative_study_write_mode_route.dart:33, verb_indicative_evaluate_route.dart:32 |
verb list, mode choice, cover, write, evaluate |
| L3 | Theme jumps. Screens alternate between a pink-tinted M3 default and white CustomTheme. |
ThemeData() at lib/src/app.dart:60, verb_selection_route.dart:47, verb_indicative_evaluate_route.dart:30; CustomTheme at studymode:29, cover:33, flashcard:32, write:31, test:34 |
home, verb list, evaluate vs the others |
| L4 | “ils/elles” truncated by a fixed 95×40 pronoun box at 25 sp. | lib/widgets/pronoun_field.dart:26-27,35 |
cover, test, all devices |
| L5 | Fixed widths that don’t grow and don’t fit large text: answer 170×40, test field 150×40, cover 180×40, expected-answer box 190×40. | text_checker.dart:62-63, verb_indicative_test_route.dart:179-181, coverable_textfield.dart:42-43,55-56, expected_row.dart:43-44 |
write, test |
| L6 | Text-scale cap of 1.3× through the deprecated textScaleFactor. Some text bypasses it. |
lib/util/text_size_limiter.dart:6; 27 call sites; unscaled text at verb_selection_route.dart:168-171 and verb_indicative_study_form_appbar.dart:33-43 |
dark + XXL sheet |
| L7 | Dark mode only partly works. Text styles are pinned to the light theme, and there are hard-coded Colors.blue/green/red. |
verb_selection_route.dart:130,140; pronoun_field.dart:34; coverable_textfield.dart:46,65; flash_card_stack.dart:166,180; verb_indicative_evaluate_route.dart:120,131,157,224 |
dark + XXL sheet |
| L8 | iPad: widgets are stretched across the full width, text is tiny, and there’s no max width. | No LayoutBuilder or breakpoints anywhere; fixed 30 dp margins at verb_selection_route.dart:108 |
iPad sheet |
| L9 | Home image is distorted (BoxFit.fill at 0.3 × width by 0.3 × height). The “Aan de slag!” button is small and low-contrast (indigo text on indigoAccent). |
lib/main.dart:88-96; custom_theme.dart:15-17 |
home |
| L10 | Tap targets are small: the help “?” is 7% of the screen width (about 26 dp), and the write-mode “next” arrow is an unlabeled bare icon. | verb_indicative_evaluate_route.dart:208; written_indicative_study_widget.dart:44-51 |
evaluate, write |
| L11 | The flashcard column doesn’t scroll, so it will overflow in iPhone landscape (inferred from code). | lib/widgets/flash_card_stack.dart:38-130 |
code |
| L12 | Inconsistent elevation and widgets. Card elevation varies (5/10/15/20), settings opens three different ways, the verb list is in HashMap order, and closing search without picking a verb dereferences filteredVerb! (possible crash, inferred). |
verb_selection_route.dart:62,247-250; settings_view.dart:26-40 |
verb list |
| L14 | The largest accessibility text size breaks the fixed 40 pt answer boxes. Typed answers spill out of the test and write fields, some answers disappear (nous, ils/elles in the filled test), the write cursor overlaps the “next” arrow, and “Enkelvoud”/”Meervoud” wrap mid-word in settings. In dark mode the field borders are visible (ThemeData.dark()), which confirms that L1 is caused by the light theme. |
verb_indicative_test_route.dart:179-181, text_checker.dart:62-63, cardinality_selection_panel.dart:47,71 |
dark + XXL sheet (frames 02, 08, 11–13) |
| L13 | Nested MaterialApps lose localizations and back behaviour. There’s no delegate on any inner app, and popAndPushNamed keeps the root stack shallow, so Android back on the verb list probably exits the app (inferred). |
the 7 routes listed in §1 | — |
What Secondaire does better (and what can be reused)
- One M3 theme throughout, with light/dark/contrast schemes (
lib/theme.dart) and colours fromTheme.of(context).colorScheme. SafeAreainside theScaffoldbody, with comments explaining why (…cover_mode_route.dart:46-54).- A header card on every study and test screen: infinitive, translation, and mode/tense chips.
- Filled, dense, always-visible answer fields (
written_indicative_study_widget.dart:256-270,verb_indicative_test_route.dart:305-322). - A large animated check, a haptic on error, and a vertical
PageViewfor write mode. - Cover-all / uncover-all (
CoverAllController). - A 600 dp content cap on tablets (
verb_selectionmode_selection_route.dart:360-380). TextScalerAPI (lib/util/text_size_limiter.dart:5). It still caps at 1.3.
Secondaire isn’t perfect either: its cover mode clips the last row (“ils/elles”) behind the bottom buttons on the iPhone 17 Pro.
3. The write method (“schrijfmethode”)
Dries’s symptom
“when I type something, the field loses focus after one character … the fields don’t intuitively show as fields.”
Result: REPRODUCED and cause proven, on iOS and on Android
Mechanism. Each step was traced in code and confirmed by test.
- Write mode’s forward button calls
Navigator.pushNamed(… VerbIndicativeTestRoute …)(lib/routes/verb_indicative_study_write_mode_route.dart:65). The write route stays mounted under the test route. - Each keystroke in a test field calls
onChanged → testModel.add(...)(lib/routes/verb_indicative_test_route.dart:195-197), andadd()callsnotifyListeners()(lib/src/model/verb_test_model.dart:83). - The hidden write route’s
Consumer<VerbTestModel>rebuildsWrittenIndicativeStudyWidget. ItsbuildcreatesTextChecker(key: UniqueKey(), …)(lib/widgets/written_indicative_study_widget.dart:61). The old field is disposed and a new one is built… - …with
autofocus: true(lib/widgets/text_checker.dart:67). The focus scope has no focused child at that moment, so Flutter hands focus, and with it the keyboard connection, to the hidden write-mode field. - The next character goes into the invisible field. Tap the visible field again and it takes exactly one more character before the cycle repeats.
Only the write → test path triggers this, because cover and flashcard mode have no autofocus field.
Evidence.
| Where | How | Result |
|---|---|---|
| Widget test (host) | evidence/focus_repro_test.dart.txt: write route → forward → showKeyboard on test field avoir_0 → testTextInput.enterText('a'), then 'ai'. This mimics a real keyboard; tester.enterText re-focuses on every call and hides the bug. |
FAILS on today’s code, iOS and Android variants: focus after 1st char: false, text "a" instead of "ai". The hidden write field actualVerb_0 has focus: true. Control: the same steps via cover mode pass (ai, focus kept). Write mode on its own also passes (ai, focus kept). |
| iOS simulator (iPhone 17 Pro, iOS 26.2), real key events | être → schrijven → type su in write mode (focus kept, r1) → → → test screen (fields invisible, r2) → tap je field → type su |
Only ”s” arrives and the cursor is gone (r3). Swiping back to write mode shows its “su” wiped and the field borderless (r4). |
Android emulator (Pixel 9, API 36), real IME via adb shell input text |
Same path. UI tree read with uiautomator dump. |
Write field: text='su' focused=true. Test field je after tap: focused=true. After typing s, then u: text='s' focused=false (screenshot). Same bug on Android. |
Raw log of the widget test:
AUDIT hidden write-mode field actualVerb_0 has focus: true, text: ""
AUDIT focus after 1st char: false; primaryFocus: FocusNode#58993([PRIMARY FOCUS])(context: Focus, PRIMARY FOCUS)
AUDIT text after 2nd char: "a"
+0 -1: REPRO: test field reached from WRITE mode keeps focus after 2 chars (variant: TargetPlatform.iOS) [E]
AUDIT hidden write-mode field actualVerb_0 has focus: true, text: ""
AUDIT focus after 1st char: false; ...
+0 -2: REPRO (Android): same, TargetPlatform.android (variant: TargetPlatform.android) [E]
AUDIT focus after 1st char: true; ... AUDIT text after 2nd char: "ai" <- CONTROL via cover mode
AUDIT write-mode focus after 1st char: true AUDIT write-mode text after 2nd char: "ai"
+2 -2: Some tests failed.
Raw Android output (uiautomator dump, EditText nodes):
--- write mode, after adb input text su
EditText text='su' focused=true bounds=[11,452][1070,604]
--- after tapping first test field
EditText text='' focused=true bounds=[415,334][938,439]
--- after typing s then u
EditText text='s' focused=false bounds=[415,334][938,439]
Why the fields don’t look like fields (proven)
Both answer fields use InputDecoration(border: OutlineInputBorder()) with no fill, hint or label, inside a fixed SizedBox of 170×40 (write) or 150×40 (test). Under CustomTheme.lightTheme, the Material 3 enabled border colour is colorScheme.outline. The probe test measured it:
AUDIT useMaterial3=true outline=Color(… red: 1.0000, green: 1.0000, blue: 1.0000 …) surface=Color(… 1.0, 1.0, 1.0 …)
The result is a white border on a white card. The border only appears (blue) while the field is focused.
Secondaire uses OutlineInputBorder with isDense, contentPadding of 8/6, filled: true and fillColor: colorScheme.surfaceContainerLowest, on a tinted secondaryContainer card. Its fields are always visible (see the “Test (start)” pair).
Other write-path findings (code evidence; not the reported symptom)
| # | Finding | Evidence | Status |
|---|---|---|---|
| W2 | Write-mode text and progress are wiped on any model notification or settings change: a UniqueKey on TextChecker, and UniqueKeys on SafeArea/Scaffold inside the nested MaterialApp. |
written_indicative_study_widget.dart:61; write_mode_route.dart:34-36 |
Observed in r4 (typed “su” wiped after returning) |
| W3 | A wrong answer of the right length keeps the previous icon colour. | text_checker.dart:89-92 |
Code evidence; unit-testable |
| W4 | The TextEditingController is never disposed (a leak for each person). |
text_checker.dart:34 (no dispose()) |
Code evidence |
| W5 | iOS autocorrect, QuickType, smart quotes/dashes and the “double space → period” shortcut are all on by default. The write-mode comparison doesn’t trim, so "suis " from QuickType turns red. |
text_checker.dart:64-80, :84-99 (no trim); test_route.dart:184-198 |
Unconfirmed; needs a real iPhone |
| W6 | Comparison has no Unicode normalisation or apostrophe mapping. The data is NFC and contains no apostrophes, and iOS soft keyboards emit NFC. | french_repository.dart (only U+00E9, U+00EA) |
Low risk; pasted or Scribble input only; unconfirmed |
| W7 | Keyboard “Done” has no onSubmitted to advance to the next person; advancing needs a tap on a small bare arrow. |
text_checker.dart:64-80; written_indicative_study_widget.dart:44-51 |
UX |
| W8 | Routes pile up: “repeat” uses popAndPushNamed over earlier write routes. |
evaluate_route.dart:79, test_route.dart:97 |
Makes W1 more likely |
Needs a real device: QuickType and inline predictions, autocorrect on focus loss, hardware-keyboard dead keys (Option-i e → ê), dictation, and Scribble on iPad.
Compared with Secondaire; can it be reused?
| Point | Secondaire | Junior |
|---|---|---|
| Ownership | Controller, FocusNode and ValueNotifier per person, created in initState and disposed (written_indicative_study_widget.dart:54-77) |
Controller only, never disposed; recreated through UniqueKey |
| Focus | Explicit requestFocus() after the first frame and on page change; no autofocus |
autofocus: true on every rebuilt field |
| Rebuild per keystroke | ValueListenableBuilder rebuilds only the icon |
setState rebuilds the whole checker |
| Model | add() does not notify (verb_test_model.dart:94-101) |
add() notifies on every keystroke (the trigger) |
| Test field | FocusEnabledTextField with its own controller and node (verb_indicative_test_route.dart:265-330) |
No controller or node |
| iOS field flags | Also not set (autocorrect etc. at defaults) | Same |
Reuse verdict. WrittenIndicativeStudyWidget ports almost as-is: only the package name changes, and it has no ads, consent or analytics imports. Take the FocusEnabledTextField class only. Do not copy Secondaire’s verb_indicative_test_route.dart: it imports streak_congratulation_route.dart, which in turn imports google_mobile_ads, ConsentManager and AdsController.
4. Reuse from Secondaire, and what must NOT be ported
Junior is for children under 13. Its published policy (docs/privacy/nl-be.md) promises no personal data, no ads, no in-app purchases and no network calls from the app.
Reusable (S = small, M = medium, L = large effort):
| Piece | Files (Secondaire) | Effort | Caveat |
|---|---|---|---|
| M3 theme | lib/theme.dart |
S | Recolour; wire into the single root app |
| Write widget | lib/widgets/written_indicative_study_widget.dart |
M | Pure; brings the correct same-length validator (:90-94) |
| Focus-safe test field | class FocusEnabledTextField in lib/routes/verb_indicative_test_route.dart:265-330 |
S | Copy the class, not the file |
VerbTestModel.add() without notify |
lib/model/verb_test_model.dart:94-101 |
S | Check that evaluate_route.dart:109,180,188 still gets its data |
TextSizeLimiter (TextScaler) |
lib/util/text_size_limiter.dart |
S | Reconsider the 1.3 cap |
| Cover-all / uncover-all | lib/model/cover_all_controller.dart, …cover_mode_route.dart:86-113 |
S | Needs ARB keys |
| Feedback widgets | score_widget.dart, evaluation_row.dart, conjugation_meta_data_card.dart, verb_card.dart, delayed_widget_builder.dart, flash_card_stack.dart |
S–M | Use Theme.of; no risky imports |
| Tablet max width | _wrapBody in verb_selectionmode_selection_route.dart:360-380 |
S | — |
| Per-verb files / extra tenses | lib/repository/verbs/*, lib/model/indicatif_*.dart |
M | Curriculum decision (#7) |
| Content tooling | scripts/list_verbs.py, report_forms.py, vetted_wrong_forms.json |
S | Offline |
| Test harness | scripts/run_all_tests.sh, integration_test_{ios,android}.sh, test/routes/*layout_test.dart |
S | — |
| Integration helpers | integration_test/util/integration_test_utils.dart |
M | Fork, don’t copy: it imports ATT, google_mobile_ads, in_app_purchase and consent (:6-19) |
| Streak / timeline | streak_engine.dart, practice_session.dart, timeline_route.dart, streak_counter_widget.dart |
M | Local-only, but PracticeSession.actuals stores the typed answers. Decision #8 |
| Release tooling | scripts/record_release.sh, fastlane/Fastfile |
S | Local git only |
Must NOT be ported:
- Ads:
lib/ads/*,lib/src/ads/*, the interstitial inverb_indicative_evaluate_route.dart:45-105, the banner inverb_selectionmode_selection_route.dart:387-400,google_mobile_ads,GADApplicationIdentifier,SKAdNetworkItems. - Consent and ATT:
lib/consent/*,splash_screen_widget.dart:25-32,app_tracking_transparency,NSUserTrackingUsageDescription. - Firebase:
firebase_options.dart,firebase.json,firebase_core,crashlytics,messaging, and the google-services/Crashlytics Gradle plugins. - IAP and store:
lib/iap/*,settings_view.dart:240-292,in_app_review. - Network and marketing: the
httpdependency, and anything touching Brevo or analytics. - Notifications (
notification_service.dart,notification_setting_widget.dart): not a network call, but it adds a permission prompt aimed at children. Leave out unless Dries decides otherwise. - Capture-only and template code:
capture_locale.dart,lib/src/sample_feature/*.
5. Code health
flutter analyze: 65 issues, all info; 0 errors and 0 warnings.- 29
deprecated_member_use: 27 ×textScaleFactor, 2 ×MaterialStateProperty. - 12
strict_top_level_inference. - 10
sort_child_properties_last. - 4
void_checks: the smoke-testasync*bug. - 4
avoid_function_literals_in_foreach_calls. - 2
unnecessary_import, 2prefer_final_fields, 1unnecessary_string_escapes, 1library_private_types_in_public_api.
- 29
- Tests: 28 files, 75/75 pass. Line coverage 82.2% (1025/1247).
lib/settingsis 28.8% (settings_view.dart0%) andlib/src/app.dart43.2%. Two tests are trivial:test/unit_test.dartchecks 1+1, andtest/cramr_ui_lib_test.dartis empty. - Integration: a single
smoke_test.dartwith 5 flows. ItsforEach((e) async* {...})at:664,684,701,736never executes. It usestester.enterText, which cannot catch focus theft. - Dependencies: nothing is discontinued, and no direct dependency is a major version behind.
intl0.20.2 → 0.20.3 andtest1.26.3 → 1.32.0 have updates.flutter_launcher_iconsbelongs in dev dependencies, andtestduplicatesflutter_test. 33 transitive packages are held back by the SDK. - SDK constraint:
sdk: ">=2.12.0 <3.0.0". Dart 3.10 treats it as <4.0, but the package runs at language version 2.12, with no Dart 3 features. Move to^3.10.0. Also, the committedpubspec.lockwas resolved with Dart ≥3.11 (commitff895b0re-resolved it for this Mac’s 3.10.4). - Dead code:
lib/generated_plugin_registrant.darthas 0 references; it also pulls inshared_preferences_web: anyandflutter_web_plugins.assets/images/flutter_logo.png*is unreferenced.mipmap-*/ic_launcher.pngis unused, because the manifest useslauncher_icon.run.cmdandsmoke_test.cmdare unused scripts, andREADME.mdis still the template.lib/repo/verb_repository.dartis in use; it is not dead code.
- Platform config:
- “AppIcon has 15 unassigned children” (today’s archive):
ios/Runner/Assets.xcassets/AppIcon.appiconset/holds 15Icon-App-*.pngfiles generated byflutter_launcher_icons, which noContents.jsonentry references (1024@1x, 20@1/2/3x, 29@1/2/3x, 40@1/2/3x, 60@2/3x, 76@1/2x, 83.5@2x).Contents.jsonstill points to hand-madeicon20…icon1024.png. Fix: remove the orphans, or use a single 1024 universal entry (Xcode 14+, iOS 12+). Also,flutter_icons.image_pathis a 60×60icon60.png, so the generated 1024 and Android xxxhdpi icons are upscaled and blurry. LaunchScreen.storyboard:19,35referencesLaunchImage, which doesn’t exist inAssets.xcassets, so the launch screen is blank.CFBundleNameis still “verbomatique”. iPhone landscape is enabled.- Android:
android/.gitignorecontains/app, which hides new files underandroid/app/.android/.gradle/6.7/**is tracked.jcenter()is still inandroid/build.gradle.ndkVersion 27.2…sits insidebuildTypes.release.ndk{}(build.gradle:107) while plugins want NDK 28.2. The proguard rules are a copied blanket list, including-keep class androidx.**. The manifestpackage=attribute is deprecated. There’s a stalekotlin/com/…/MainActivity.kt. - The root
.gitignorecontradicts what is tracked: it ignorespubspec.lock,**/ios/Flutter,*.imland.DS_Store, yet those are tracked.xcuserdata/is tracked too. 20 files have CRLF line endings and there’s no.gitattributes.
- “AppIcon has 15 unassigned children” (today’s archive):
6. Work plan
Hours are rough Claude Code hours, including tests and screenshots. Risk is L (low), M (medium) or H (high).
Phase A — Write-method reliability (first)
| # | Item | Effort | Risk | Verified by | Needs from Dries |
|---|---|---|---|---|---|
| A1 | Stop the focus theft: port Secondaire’s WrittenIndicativeStudyWidget (owned controller, FocusNode, explicit requestFocus, no UniqueKey, no autofocus). Make VerbTestModel.add() not notify. Use pushReplacement or pop the study route when entering the test. |
M · 4–6 h | M (evaluate route relies on watch) |
focus_repro_test.dart turns green; new iOS and Android integration test that types via testTextInput; simulator re-run of the r1–r4 path |
Decision #1 |
| A2 | Focus-safe test fields (the FocusEnabledTextField pattern), textInputAction: next to move between persons, onSubmitted to advance |
S · 2 h | L | Widget test: type in all 6 fields in sequence; Android + iOS integration | — |
| A3 | Keyboard flags on every answer field: autocorrect:false, enableSuggestions:false, smartQuotesType/smartDashesType: disabled, textCapitalization.none, keyboardType: text |
S · 1 h | L | Widget test asserting the flags; real-iPhone check | Decision #2; device test |
| A4 | Answer normalisation for write and test: trim, collapse spaces, map ’/ʼ to ', NFC. Fix the same-length-wrong bug (W3). |
S · 2 h | L | Unit tests for each variant | — |
| A5 | Visible fields: filled, dense field style in a shared widget; a theme with a real outline; a proper tap target |
S · 2 h | L | Before/after screenshots on all 3 simulators | Colour choice (can be interim) |
| A6 | Fix the smoke test’s async* loops, dispose controllers, and add a write → test → evaluate integration test to the CI script |
S · 2 h | L | flutter test integration_test/smoke_test.dart on iOS sim + Android emu |
— |
| Phase A total | ≈ 13–15 h |
Phase B — Layout and design alignment with Secondaire
| # | Item | Effort | Risk | Verified by | Needs from Dries |
|---|---|---|---|---|---|
| B1 | One MaterialApp: remove the 7 nested apps; one theme, one navigator, localizations everywhere; explicit back navigation; clean route stack |
L · 8–12 h | H (touches every route; back behaviour changes) | Full integration suite on both platforms; back-button test ported from Secondaire | Decision #4 |
| B2 | M3 theme from Secondaire’s theme.dart, recoloured for Junior: light and dark; colours from colorScheme; drop hard-coded Colors.* |
M · 4–6 h | M | Screenshot sweep in light/dark; contrast check | Design choice (palette) #3 |
| B3 | SafeArea inside Scaffold everywhere (removes the black bands); resizeToAvoidBottomInset handling |
S · 1–2 h (with B1) | L | Screenshot sweep; port Secondaire’s assert_status_bar_clean idea |
— |
| B4 | Flexible layouts: remove fixed boxes; Expanded/Flexible; full “ils/elles”; iPad max width 600; scrollable flashcard column; lock iPhone portrait |
M · 5–7 h | M | Screenshots on SE, 17 Pro and iPad; layout widget tests (Secondaire style) | Decision #6 |
| B5 | Text scaling: move to TextScaler, raise the cap for key text, test at the largest accessibility size |
M · 3–4 h | M | Screenshots at XXL; overflow widget tests | Decision #5 |
| B6 | Secondaire-style screens: header card (infinitive, translation, tense chip), write-mode card with animated check and haptic, evaluation rows and score, cover-all bar | M · 6–8 h | M | Paired screenshots vs Secondaire | Design sign-off |
| B7 | Localisation: move hard-coded Dutch to app_nl.arb; delegates everywhere |
M · 4–5 h | L | flutter gen-l10n; locale integration test |
Decision #9 |
| Phase B total | ≈ 31–44 h |
Phase C — Polish and optional extras
| # | Item | Effort | Risk | Verified by | Needs from Dries |
|---|---|---|---|---|---|
| C1 | App icon: 1024 source, single-size set, remove the 15 orphans; fix LaunchImage |
S · 1–2 h | L | Archive without the warning; icon on device | Approve icon |
| C2 | SDK ^3.10.0, fix 29 deprecations and remaining lints, dev-dependency hygiene, drop dead code |
M · 3–4 h | L | flutter analyze = 0; tests green |
— |
| C3 | Repo hygiene: .gitignore/tracked files, .gitattributes + renormalise CRLF, stale MainActivity, Android build (NDK, jcenter, proguard, package=) |
M · 3 h | M (Android release build) | Clean git status after builds; release AAB builds |
— |
| C4 | Content: savoir → “weten”; sorted verb list; search-close crash; duplicate search results |
S · 1–2 h | L | Unit/widget tests | — |
| C5 | Tests: settings view and app coverage; keep the capture harness as a committed script for regression screenshots | M · 3–4 h | L | Coverage report; screenshot run | — |
| Phase C core total | ≈ 11–15 h | ||||
| C6 (optional) | More tenses for Junior (per-verb files from Secondaire) | L · 6–10 h | M | Content unit tests | Decision #7 (curriculum) |
| C7 (optional) | Local history/streak without stored answers, plus a privacy-policy update | M–L · 6–10 h | M (privacy) | Unit tests; policy review | Decision #8 |
| Optional total | ≈ 12–20 h |
Overall:
| Phase | Hours |
|---|---|
| A | 13–15 h |
| B | 31–44 h |
| C core | 11–15 h |
| Launch-ready total | ≈ 55–74 h |
| Optional extras | +12–20 h |
Appendix — how this audit was produced
- Architecture, write-path and code-health reviews: read-only subagents over the Junior tree and a
git archiveexport of Secondairemain.flutter analyze/test/pub outdatedran in a throwaway copy. - Screenshots:
integration_testcapture harnesses in throwaway copies of both apps. Each capture step writes a request file into the app container; a host script takes a full-devicesimctl io screenshotand acknowledges. The status bar was overridden to 9:41. Secondaire ran withDISABLE_ADS=trueand its fake consent API, as in its own screenshot pipeline. - Repro: the widget test is in
evidence/focus_repro_test.dart.txt(stored as.dart.txtso it is not part of the app test suite or analysis; copy it totest/…_test.dartto run). The theme probe isevidence/theme_probe_test.dart.txt. iOS repro used real key events on the simulator. Android repro usedadb shell input texton a Pixel 9 API 36 emulator. - Nothing was changed in app code, in the Secondaire repo, in the stores or in App Store Connect.







