Files
valuecards/lib/providers/values_sort_provider.dart
T
2026-09-18 00:01:42 +02:00

456 lines
12 KiB
Dart

/// 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<String> 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<ValueCard> _deck = const [];
List<ValueCard> get deck => _deck;
/// Remaining unsorted cards (front of list is current).
final List<ValueCard> _unsorted = [];
List<ValueCard> get unsorted => List.unmodifiable(_unsorted);
ValueCard? get currentCard =>
_unsorted.isEmpty ? null : _unsorted.first;
final Map<String, ValuePile> _assignments = {};
Map<String, ValuePile> get assignments => Map.unmodifiable(_assignments);
/// Ordered top values (index 0 = #1 / North Star).
final List<String> _rankedIds = [];
List<String> get rankedIds => List.unmodifiable(_rankedIds);
/// Reflections keyed by `valueId|promptIndex`.
final Map<String, String> _reflections = {};
ValuePile? _reviewPile;
ValuePile? get reviewPile => _reviewPile;
String? _activeReflectionValueId;
String? get activeReflectionValueId => _activeReflectionValueId;
int _activePromptIndex = 0;
int get activePromptIndex => _activePromptIndex;
final List<String> _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<void> load({List<ValueCard>? 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<ValueCard> 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<ValueCard> get rankedCards => _rankedIds
.map(cardById)
.whereType<ValueCard>()
.toList(growable: false);
/// Top values used for reflection (up to 5).
List<ValueCard> 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<void> _persistNow() async {
try {
final payload = <String, dynamic>{
'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<String>.from(_rankedIds),
'reflections': Map<String, String>.from(_reflections),
'activeReflectionValueId': _activeReflectionValueId,
'activePromptIndex': _activePromptIndex,
'undoStack': List<String>.from(_undoStack),
};
await _storage.setSortSessionJson(jsonEncode(payload));
} catch (_) {
// Persistence failures should not break the practice UI.
}
}
Future<void> _restoreSession() async {
final raw = _storage.sortSessionJson;
if (raw == null || raw.isEmpty) return;
try {
final data = jsonDecode(raw) as Map<String, dynamic>;
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<String, dynamic>() ?? {};
_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<String>() ?? const <String>[];
_unsorted
..clear()
..addAll(
unsortedIds
.map(cardById)
.whereType<ValueCard>(),
);
// 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<String>() ?? const <String>[])
.where(knownIds.contains),
);
_syncRankingWithMostImportant();
_reflections
..clear()
..addAll(
((data['reflections'] as Map?)?.cast<String, dynamic>() ?? {})
.map((k, v) => MapEntry(k, '$v')),
);
_undoStack
..clear()
..addAll(
((data['undoStack'] as List?)?.cast<String>() ?? const <String>[])
.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();
}
}