/// Session state for the Values Sort practice: sorting, ranking, reflection. library; import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import '../data/values_deck.dart'; import '../models/value_card.dart'; import '../models/value_pile.dart'; import '../services/storage_service.dart'; /// Contemplative prompts shown during the reflection stage. const List kReflectionPrompts = [ 'What does this value mean to you?', 'Why did you choose this as important?', 'When do you live this value most fully?', 'What gets in the way of honoring this value?', ]; /// Stages in the guided practice flow. enum SortStage { welcome, sorting, hub, overview, pileReview, ranking, reflection, } /// ChangeNotifier that owns the deck, pile assignments, ranking, and journals. class ValuesSortController extends ChangeNotifier { ValuesSortController({required StorageService this._storage}); final StorageService _storage; bool _loading = true; bool get loading => _loading; String? _error; String? get error => _error; SortStage _stage = SortStage.welcome; SortStage get stage => _stage; List _deck = const []; List get deck => _deck; /// Remaining unsorted cards (front of list is current). final List _unsorted = []; List get unsorted => List.unmodifiable(_unsorted); ValueCard? get currentCard => _unsorted.isEmpty ? null : _unsorted.first; final Map _assignments = {}; Map get assignments => Map.unmodifiable(_assignments); /// Ordered top values (index 0 = #1 / North Star). final List _rankedIds = []; List get rankedIds => List.unmodifiable(_rankedIds); /// Reflections keyed by `valueId|promptIndex`. final Map _reflections = {}; ValuePile? _reviewPile; ValuePile? get reviewPile => _reviewPile; String? _activeReflectionValueId; String? get activeReflectionValueId => _activeReflectionValueId; int _activePromptIndex = 0; int get activePromptIndex => _activePromptIndex; final List _undoStack = []; bool _restoring = false; /// True when a non-welcome session is on disk / in memory. bool get hasInProgressSession => _stage != SortStage.welcome || _assignments.isNotEmpty || _reflections.isNotEmpty; Future load({List? seedDeck}) async { _loading = true; _error = null; notifyListeners(); try { final cards = seedDeck ?? await loadValuesDeck(); _deck = cards; _resetSortState(); await _restoreSession(); } catch (e) { _error = e.toString(); } finally { _loading = false; notifyListeners(); } } String get _deckFingerprint => _deck.map((c) => c.id).join('|'); void _resetSortState() { _assignments.clear(); _undoStack.clear(); _unsorted ..clear() ..addAll(_deck); _rankedIds.clear(); _reflections.clear(); _reviewPile = null; _activeReflectionValueId = null; _activePromptIndex = 0; } void beginPractice() { if (_deck.isEmpty) return; _resetSortState(); _stage = SortStage.sorting; _commit(); } void restart() { _resetSortState(); _stage = SortStage.welcome; _commit(clear: true); } int get sortedCount => _assignments.length; int get totalCount => _deck.length; double get sortProgress => totalCount == 0 ? 0 : sortedCount / totalCount; bool get isSortComplete => _unsorted.isEmpty && _assignments.length == _deck.length; List cardsInPile(ValuePile pile) { return _deck .where((c) => _assignments[c.id] == pile) .toList(growable: false); } int countInPile(ValuePile pile) => cardsInPile(pile).length; ValueCard? cardById(String id) { for (final c in _deck) { if (c.id == id) return c; } return null; } void assignCurrent(ValuePile pile) { final card = currentCard; if (card == null) return; _undoStack.add(card.id); _assignments[card.id] = pile; _unsorted.removeAt(0); if (isSortComplete) { _seedRankingFromMostImportant(); _stage = SortStage.hub; } _commit(); } void undoLastSort() { if (_undoStack.isEmpty) return; final id = _undoStack.removeLast(); _assignments.remove(id); final card = cardById(id); if (card != null) { _unsorted.insert(0, card); } if (_stage != SortStage.sorting) { _stage = SortStage.sorting; } _commit(); } void moveCard(String id, ValuePile pile) { if (!_assignments.containsKey(id) && !_unsorted.any((c) => c.id == id)) { return; } _unsorted.removeWhere((c) => c.id == id); _assignments[id] = pile; _rankedIds.remove(id); _syncRankingWithMostImportant(); _commit(); } void _seedRankingFromMostImportant() { _rankedIds ..clear() ..addAll(cardsInPile(ValuePile.mostImportant).map((c) => c.id)); } void _syncRankingWithMostImportant() { final most = cardsInPile(ValuePile.mostImportant).map((c) => c.id).toSet(); _rankedIds.removeWhere((id) => !most.contains(id)); for (final id in most) { if (!_rankedIds.contains(id)) { _rankedIds.add(id); } } } void goToHub() { _stage = SortStage.hub; _commit(); } void goToOverview() { _stage = SortStage.overview; _commit(); } void openPileReview(ValuePile pile) { _reviewPile = pile; _stage = SortStage.pileReview; _commit(); } void goToRanking() { _syncRankingWithMostImportant(); if (_rankedIds.isEmpty) { final fallback = cardsInPile(ValuePile.veryImportant); for (final c in fallback.take(8)) { _assignments[c.id] = ValuePile.mostImportant; _rankedIds.add(c.id); } } _stage = SortStage.ranking; _commit(); } void reorderRanked(int oldIndex, int newIndex) { final id = _rankedIds.removeAt(oldIndex); _rankedIds.insert(newIndex, id); _commit(); } void moveRank(int index, {required bool up}) { final target = up ? index - 1 : index + 1; if (target < 0 || target >= _rankedIds.length) return; final id = _rankedIds.removeAt(index); _rankedIds.insert(target, id); _commit(); } List get rankedCards => _rankedIds .map(cardById) .whereType() .toList(growable: false); /// Top values used for reflection (up to 5). List get coreValues => rankedCards.take(5).toList(growable: false); void goToReflection({String? valueId}) { final cores = coreValues; if (valueId != null && cores.any((c) => c.id == valueId)) { _activeReflectionValueId = valueId; } else { _activeReflectionValueId = cores.isEmpty ? null : cores.first.id; } _activePromptIndex = 0; _stage = SortStage.reflection; _commit(); } void selectReflectionValue(String id) { _activeReflectionValueId = id; _activePromptIndex = 0; _commit(); } void selectPrompt(int index) { _activePromptIndex = index.clamp(0, kReflectionPrompts.length - 1); _commit(); } String reflectionKey(String valueId, int promptIndex) => '$valueId|$promptIndex'; String reflectionFor(String valueId, int promptIndex) => _reflections[reflectionKey(valueId, promptIndex)] ?? ''; void setReflection(String valueId, int promptIndex, String text) { final key = reflectionKey(valueId, promptIndex); if (text.trim().isEmpty) { _reflections.remove(key); } else { _reflections[key] = text.trim(); } _commit(); } int answeredPromptCount(String valueId) { var n = 0; for (var i = 0; i < kReflectionPrompts.length; i++) { if (reflectionFor(valueId, i).isNotEmpty) n++; } return n; } int get totalAnsweredReflections { var n = 0; for (final c in coreValues) { n += answeredPromptCount(c.id); } return n; } int get totalReflectionSlots => coreValues.length * kReflectionPrompts.length; void _commit({bool clear = false}) { notifyListeners(); if (_restoring) return; if (clear || (_stage == SortStage.welcome && _assignments.isEmpty)) { unawaited(_storage.clearSortSession()); return; } unawaited(_persistNow()); } Future _persistNow() async { try { final payload = { 'version': 1, 'deckFingerprint': _deckFingerprint, 'stage': _stage.name, 'reviewPile': _reviewPile?.name, 'assignments': { for (final e in _assignments.entries) e.key: e.value.name, }, 'unsortedIds': _unsorted.map((c) => c.id).toList(growable: false), 'rankedIds': List.from(_rankedIds), 'reflections': Map.from(_reflections), 'activeReflectionValueId': _activeReflectionValueId, 'activePromptIndex': _activePromptIndex, 'undoStack': List.from(_undoStack), }; await _storage.setSortSessionJson(jsonEncode(payload)); } catch (_) { // Persistence failures should not break the practice UI. } } Future _restoreSession() async { final raw = _storage.sortSessionJson; if (raw == null || raw.isEmpty) return; try { final data = jsonDecode(raw) as Map; if (data['deckFingerprint'] != _deckFingerprint) { await _storage.clearSortSession(); return; } _restoring = true; final knownIds = _deck.map((c) => c.id).toSet(); final assignmentsRaw = (data['assignments'] as Map?)?.cast() ?? {}; _assignments.clear(); for (final e in assignmentsRaw.entries) { final pile = ValuePileX.fromName('${e.value}'); if (pile != null && knownIds.contains(e.key)) { _assignments[e.key] = pile; } } final unsortedIds = (data['unsortedIds'] as List?)?.cast() ?? const []; _unsorted ..clear() ..addAll( unsortedIds .map(cardById) .whereType(), ); // Recover any deck cards missing from both maps (deck updates / corruption). final present = {..._assignments.keys, ..._unsorted.map((c) => c.id)}; for (final card in _deck) { if (!present.contains(card.id)) { _unsorted.add(card); } } _rankedIds ..clear() ..addAll( ((data['rankedIds'] as List?)?.cast() ?? const []) .where(knownIds.contains), ); _syncRankingWithMostImportant(); _reflections ..clear() ..addAll( ((data['reflections'] as Map?)?.cast() ?? {}) .map((k, v) => MapEntry(k, '$v')), ); _undoStack ..clear() ..addAll( ((data['undoStack'] as List?)?.cast() ?? const []) .where(knownIds.contains), ); _reviewPile = ValuePileX.fromName(data['reviewPile'] as String?); _activeReflectionValueId = data['activeReflectionValueId'] as String?; if (_activeReflectionValueId != null && !knownIds.contains(_activeReflectionValueId)) { _activeReflectionValueId = null; } _activePromptIndex = (data['activePromptIndex'] as num?)?.toInt() ?? 0; final stageName = data['stage'] as String? ?? SortStage.welcome.name; _stage = SortStage.values.firstWhere( (s) => s.name == stageName, orElse: () => SortStage.welcome, ); // If restored mid-sort but nothing left unsorted, land on the hub. if (_stage == SortStage.sorting && isSortComplete) { _seedRankingFromMostImportant(); _stage = SortStage.hub; } if (_stage == SortStage.pileReview && _reviewPile == null) { _stage = SortStage.hub; } } catch (_) { await _storage.clearSortSession(); _resetSortState(); _stage = SortStage.welcome; } finally { _restoring = false; } } @override void dispose() { if (!_restoring && hasInProgressSession) { unawaited(_persistNow()); } super.dispose(); } }