Wrap jsonDecode() calls with try-catch and fallback to defaults (matching the pattern already used in StatsService and AdaptiveDifficultyService).
Files to edit:
lib/services/progress_service.dartβ_loadProgress()line 75: wrapjsonDecode(raw)in try-catch, fallback to_progress = {}lib/services/high_score_service.dartβgetHighScores()line 74: wrapjsonDecode(raw)in try-catch, return[]lib/services/review_service.dartβ_loadReviews()line 39: wrapjsonDecode(raw)in try-catch, fallback to_reviews = {}lib/services/streak_service.dartβ_load()line 41: wrapjsonDecode(raw)in try-catch, fallback to defaults (0 streak)
- Add
dispose()method toProgressServicethat cancels_saveTimerand calls_flushSave() - Add
_progressService.dispose()call inapp.dartdispose()(line 195)
In app.dart _ReadingSproutAppState:
- Add
with WidgetsBindingObservermixin - Register in
initState():WidgetsBinding.instance.addObserver(this) - Remove in
dispose():WidgetsBinding.instance.removeObserver(this) - Implement
didChangeAppLifecycleState():- On
paused/inactive: call_adaptiveMusicService.pause()and_progressService.flushSave() - On
resumed: call_adaptiveMusicService.resume()
- On
Replace all Color.lerp(a, b, t)! with Color.lerp(a, b, t) ?? a (fallback to the first color argument). This is a mechanical find-and-replace with contextual awareness:
- Lines 991, 992, 999, 1026, 1045, 1063, 1118, 1137 (skin colors β fallback to
skinColor) - Lines 1184, 1185, 1206, 1215, 1236 (ear/nose β fallback to first arg)
- Lines 1498-1595 (eye colors β fallback to
eyeColor) - Lines 1670-1798 (eye highlights β fallback to first arg)
- Lines 2043-2077 (effects β fallback to first arg)
- Lines 2210-2512 (mouth β fallback to first arg)
- Lines 2797-3024 (nose β fallback to first arg)
- Lines 3303-3493 (eyebrow/shadow β fallback to first arg)
- Line 87: Replace
HSLColor.lerp(hslA, hslB, t)!.toColor()with(HSLColor.lerp(hslA, hslB, t) ?? hslA).toColor()
In lib/avatar/animation/skeleton.dart lines 171-193, add debug assertions to the 23 bone getters:
Bone get head { assert(bones.containsKey('head'), 'head bone missing'); return bones['head']!; }This preserves non-nullable return types (needed throughout avatar rendering) while catching initialization bugs in debug mode.
lib/screens/mini_games/paint_splash_game.dartline ~904: Guard_blobs.firstwith isEmpty checklib/screens/mini_games/rhyme_time_game.dartline ~256: GuardfamilyWords.firstwith isEmpty checklib/services/high_score_service.dartline 90: Guardscores.firstwith isEmpty check
In lib/services/avatar_personality_service.dart:
- Add
switchProfile(String profileId)method that:- Saves current
_activeif dirty - Clears
_active,_activeProfileId,_sessionCorrect,_sessionTotal - Sets
_activeProfileId = profileId
- Saves current
- Call it from
app.dart_applyProfileScope()
The saveScore() method at line 56 also calls jsonDecode indirectly through getHighScores(). Ensure the full save path is safe.
The key insight: Element Lab already uses the efficient pattern (ValueNotifier, no setState). FloatingHeartsBackground uses the ChangeNotifier + CustomPainter pattern. We'll adapt games to separate their rendering from widget tree rebuilds.
For each game, the approach is:
- Extract game state into a
ChangeNotifiersimulation class - Move physics/update logic into
tick()on the simulation - Use
CustomPainter(repaint: simulation)to drive canvas repaints - Keep UI overlay widgets (score, buttons) updated via
ValueListenableBuilderor targetedsetState()only when score/lives/state changes (not every frame)
Given the scope (17 games, 25K+ total lines), this is a large refactor. Prioritize by complexity and user-facing impact:
High priority (continuous physics, many particles):
word_bubbles_game.dart(2036 lines) β bubble physics, fish, seaweed, particlesunicorn_flight_game.dart(2328 lines) β flight physics, obstacles, particlesfalling_letters_game.dart(1899 lines) β gravity, shockwaves, fragmentscat_letter_toss_game.dart(1613 lines) β projectile physics, targetspaint_splash_game.dart(1486 lines) β splat physics, particlesstar_catcher_game.dart(1455 lines) β star movement, catching
Medium priority (simpler animations):
7. word_ninja_game.dart (1330 lines)
8. word_rocket_game.dart (977 lines)
9. word_train_game.dart (1183 lines)
10. ladybug_game.dart (1718 lines)
11. rhyme_time_game.dart (1433 lines)
12. lightning_speller_game.dart (1509 lines)
13. letter_drop_game.dart (1604 lines) β uses Forge2D physics engine
Low priority (event-driven, minimal continuous rendering):
14. memory_match_game.dart (921 lines) β discrete animations, no game loop
15. spelling_bee_game.dart (811 lines)
16. sight_word_safari_game.dart (800 lines)
17. element_lab_game.dart (4847 lines) β already optimized (ValueNotifier pattern)
In lib/screens/game_screen.dart (lines 213-230):
- Store
_shakeStatusListenerand_nudgeStatusListeneras named functions - Remove them in
dispose()before calling.dispose()on controllers
Search all Image.asset() calls and add cacheWidth/cacheHeight where explicit dimensions are provided. Key files:
lib/app.dart(logo)lib/screens/(any screen with images)
In app.dart _init(), wrap each service init in a Stopwatch and log any that take > 500ms:
final sw = Stopwatch()..start();
await _progressService.init(prefs);
if (sw.elapsedMilliseconds > 500) debugPrint('SLOW: ProgressService ${sw.elapsedMilliseconds}ms');Phase 1 (Critical) βββ Phase 2 (Null Safety) βββ Phase 3 (Data) βββ Phase 4 (Perf) βββ Phase 5 (Polish)
~30 min ~45 min ~15 min ~3-4 hrs ~20 min
- Phases 1-3 are independent of each other and can be done in parallel
- Phase 4 is the largest effort and can be done incrementally (game by game)
- Phase 5 is low-risk polish
- Generating missing
assets/audio/music/files (requires external tools/composers) - Generating missing
assets/audio/phrases/files (requires TTS generation scripts) - Integrating mini games into adventure mode levels
- Integrating 49 bonus words into adventure
- Screen reader Semantics labels
- Streak data migration from SharedPreferences to Hive