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.

  1. Write-method fix. (a) Recommended: port Secondaire’s WrittenIndicativeStudyWidget and FocusEnabledTextField pattern. (b) A minimal patch: remove the UniqueKey and autofocus, and stop add() from notifying. (a) costs about 2 hours more and brings the animated check and haptics with it.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. History/streak screen. Secondaire’s streak and timeline are local-only. Its PracticeSession stores 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 update docs/privacy/nl-be.md.
  9. Languages. The UI is hard-coded Dutch and only app_en.arb exists. Move to app_nl.arb, with nl-BE only, or add English too.
  10. 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

  1. 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 calls notifyListeners(). The hidden write-mode TextChecker is then recreated under a UniqueKey(), and its autofocus: true takes focus back. After the first character, focus sits on the invisible write-mode field. See §3.
  2. Answer fields are invisible until tapped. CustomTheme.lightTheme builds its colours with ColorScheme.fromSwatch(), which resolves outline to white (#FFFFFF). Material 3 draws the unfocused OutlineInputBorder in outline, so the result is a white border on a white card. This was measured with a probe test.
  3. Seven nested MaterialApps with two different themes. They cause:
    • colour and background jumps between screens (pink-tinted ThemeData() versus white CustomTheme);
    • black bands above and below five screens (SafeArea outside Scaffold);
    • no localizations inside the routes;
    • a navigation stack that grows with hidden routes, which is what makes finding 1 possible.
  4. 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.
  5. The tests look green but miss this. 75 of 75 unit/widget tests pass and line coverage is 82%. But flutter test never exercises the route stack. The smoke test’s forEach((e) async* {...}) blocks (integration_test/smoke_test.dart:664,684,701,736) never run, and tester.enterText re-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. SettingsView is 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 from Theme.of(context).colorScheme.
  • SafeArea inside the Scaffold body, 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 PageView for write mode.
  • Cover-all / uncover-all (CoverAllController).
  • A 600 dp content cap on tablets (verb_selectionmode_selection_route.dart:360-380).
  • TextScaler API (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.

  1. 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.
  2. Each keystroke in a test field calls onChanged → testModel.add(...) (lib/routes/verb_indicative_test_route.dart:195-197), and add() calls notifyListeners() (lib/src/model/verb_test_model.dart:83).
  3. The hidden write route’s Consumer<VerbTestModel> rebuilds WrittenIndicativeStudyWidget. Its build creates TextChecker(key: UniqueKey(), …) (lib/widgets/written_indicative_study_widget.dart:61). The old field is disposed and a new one is built…
  4. …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.
  5. 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 in verb_indicative_evaluate_route.dart:45-105, the banner in verb_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 http dependency, 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-test async* bug.
    • 4 avoid_function_literals_in_foreach_calls.
    • 2 unnecessary_import, 2 prefer_final_fields, 1 unnecessary_string_escapes, 1 library_private_types_in_public_api.
  • Tests: 28 files, 75/75 pass. Line coverage 82.2% (1025/1247). lib/settings is 28.8% (settings_view.dart 0%) and lib/src/app.dart 43.2%. Two tests are trivial: test/unit_test.dart checks 1+1, and test/cramr_ui_lib_test.dart is empty.
  • Integration: a single smoke_test.dart with 5 flows. Its forEach((e) async* {...}) at :664,684,701,736 never executes. It uses tester.enterText, which cannot catch focus theft.
  • Dependencies: nothing is discontinued, and no direct dependency is a major version behind. intl 0.20.2 → 0.20.3 and test 1.26.3 → 1.32.0 have updates. flutter_launcher_icons belongs in dev dependencies, and test duplicates flutter_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 committed pubspec.lock was resolved with Dart ≥3.11 (commit ff895b0 re-resolved it for this Mac’s 3.10.4).
  • Dead code:
    • lib/generated_plugin_registrant.dart has 0 references; it also pulls in shared_preferences_web: any and flutter_web_plugins.
    • assets/images/flutter_logo.png* is unreferenced.
    • mipmap-*/ic_launcher.png is unused, because the manifest uses launcher_icon.
    • run.cmd and smoke_test.cmd are unused scripts, and README.md is still the template.
    • lib/repo/verb_repository.dart is in use; it is not dead code.
  • Platform config:
    • “AppIcon has 15 unassigned children” (today’s archive): ios/Runner/Assets.xcassets/AppIcon.appiconset/ holds 15 Icon-App-*.png files generated by flutter_launcher_icons, which no Contents.json entry 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.json still points to hand-made icon20…icon1024.png. Fix: remove the orphans, or use a single 1024 universal entry (Xcode 14+, iOS 12+). Also, flutter_icons.image_path is a 60×60 icon60.png, so the generated 1024 and Android xxxhdpi icons are upscaled and blurry.
    • LaunchScreen.storyboard:19,35 references LaunchImage, which doesn’t exist in Assets.xcassets, so the launch screen is blank.
    • CFBundleName is still “verbomatique”. iPhone landscape is enabled.
    • Android: android/.gitignore contains /app, which hides new files under android/app/. android/.gradle/6.7/** is tracked. jcenter() is still in android/build.gradle. ndkVersion 27.2… sits inside buildTypes.release.ndk{} (build.gradle:107) while plugins want NDK 28.2. The proguard rules are a copied blanket list, including -keep class androidx.**. The manifest package= attribute is deprecated. There’s a stale kotlin/com/…/MainActivity.kt.
    • The root .gitignore contradicts what is tracked: it ignores pubspec.lock, **/ios/Flutter, *.iml and .DS_Store, yet those are tracked. xcuserdata/ is tracked too. 20 files have CRLF line endings and there’s no .gitattributes.

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 archive export of Secondaire main. flutter analyze/test/pub outdated ran in a throwaway copy.
  • Screenshots: integration_test capture harnesses in throwaway copies of both apps. Each capture step writes a request file into the app container; a host script takes a full-device simctl io screenshot and acknowledges. The status bar was overridden to 9:41. Secondaire ran with DISABLE_ADS=true and 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.txt so it is not part of the app test suite or analysis; copy it to test/…_test.dart to run). The theme probe is evidence/theme_probe_test.dart.txt. iOS repro used real key events on the simulator. Android repro used adb shell input text on a Pixel 9 API 36 emulator.
  • Nothing was changed in app code, in the Secondaire repo, in the stores or in App Store Connect.