import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; import 'streak.dart'; class StageProgress { const StageProgress({required this.stars, required this.bestScore}); final int stars; final int bestScore; } /// Versioned JSON-over-prefs persistence for progress. Total payload stays /// tiny (<100 KB), so a key-value blob beats a database here. class SaveRepository { SaveRepository(this._prefs) { final raw = _prefs.getString(_key); if (raw != null) { final json = jsonDecode(raw) as Map; final progress = json['progress'] as Map? ?? {}; for (final entry in progress.entries) { final value = entry.value as Map; _progress[entry.key] = StageProgress( stars: value['stars'] as int, bestScore: value['bestScore'] as int, ); } final streak = json['streak'] as Map?; if (streak != null) { _streak = StreakState( current: streak['current'] as int, best: streak['best'] as int, lastYmd: streak['lastYmd'] as String?, ); } _tutorialDone = (json['flags'] as Map?)?['tutorialDone'] as bool? ?? false; _endlessBest = (json['endless'] as Map?)?['best'] as int? ?? 0; _adsRemoved = (json['flags'] as Map?)?['adsRemoved'] as bool? ?? false; } } static const _key = 'save_v1'; static Future open() async => SaveRepository(await SharedPreferences.getInstance()); final SharedPreferences _prefs; final Map _progress = {}; StreakState _streak = StreakState.initial; bool _tutorialDone = false; int _endlessBest = 0; bool _adsRemoved = false; StreakState get streak => _streak; bool get tutorialDone => _tutorialDone; int get endlessBest => _endlessBest; bool get adsRemoved => _adsRemoved; Future markTutorialDone() { _tutorialDone = true; return _flush(); } Future setAdsRemoved(bool value) { _adsRemoved = value; return _flush(); } Future recordEndlessScore(int score) { if (score > _endlessBest) _endlessBest = score; return _flush(); } Future saveStreak(StreakState streak) { _streak = streak; return _flush(); } static String _id(String seasonId, String stageId) => '$seasonId/$stageId'; /// Immutable copy of all progress, keyed `seasonId/stageId`. Map snapshot() => Map.unmodifiable(_progress); StageProgress? progressFor(String seasonId, String stageId) => _progress[_id(seasonId, stageId)]; Future recordResult({ required String seasonId, required String stageId, required int stars, required int score, }) async { final id = _id(seasonId, stageId); final old = _progress[id]; _progress[id] = StageProgress( stars: old == null || stars > old.stars ? stars : old.stars, bestScore: old == null || score > old.bestScore ? score : old.bestScore, ); await _flush(); } int totalStars(String seasonId) { var total = 0; for (final entry in _progress.entries) { if (entry.key.startsWith('$seasonId/')) total += entry.value.stars; } return total; } /// Index of the furthest playable stage: the first without a star, or the /// last stage when everything is starred. int highestUnlockedIndex(String seasonId, List stageIds) { for (var i = 0; i < stageIds.length; i++) { final progress = progressFor(seasonId, stageIds[i]); if (progress == null || progress.stars == 0) return i; } return stageIds.length - 1; } Future _flush() => _prefs.setString( _key, jsonEncode({ 'saveVersion': 1, 'progress': { for (final entry in _progress.entries) entry.key: { 'stars': entry.value.stars, 'bestScore': entry.value.bestScore, }, }, 'streak': { 'current': _streak.current, 'best': _streak.best, 'lastYmd': _streak.lastYmd, }, 'flags': {'tutorialDone': _tutorialDone, 'adsRemoved': _adsRemoved}, 'endless': {'best': _endlessBest}, }), ); }