full app done

This commit is contained in:
davmar
2026-09-18 00:01:42 +02:00
parent 8475abeddb
commit 78fd23f69f
74 changed files with 8104 additions and 1963 deletions
-93
View File
@@ -1,93 +0,0 @@
/// [ChangeNotifier] that loads, filters, and mutates the in-memory item list.
library;
import 'package:flutter/foundation.dart';
import '../models/item.dart';
import '../services/item_repository.dart';
import '../services/storage_service.dart';
/// App-wide catalog state. Screens call [loadItems], [addItem], and [search].
class ItemController extends ChangeNotifier {
ItemController({
required this._repository,
required this._storage,
}) {
_repository.useRemoteData = _storage.useRemoteData;
}
final ItemRepository _repository;
final StorageService _storage;
List<Item> _items = const [];
bool _isLoading = false;
String? _error;
List<Item> get items => List.unmodifiable(_items);
bool get isLoading => _isLoading;
String? get error => _error;
bool get useRemoteData => _repository.useRemoteData;
int get count => _items.length;
Future<void> loadItems() async {
_isLoading = true;
_error = null;
notifyListeners();
try {
// Small delay so the loading indicator is visible with local data.
await Future<void>.delayed(const Duration(milliseconds: 250));
_items = await _repository.fetchItems();
} catch (error, stackTrace) {
_error = 'Could not load items.';
debugPrint('ItemController.loadItems failed: $error\n$stackTrace');
} finally {
_isLoading = false;
notifyListeners();
}
}
Future<void> setUseRemoteData(bool value) async {
if (value == _repository.useRemoteData) return;
_repository.useRemoteData = value;
await _storage.setUseRemoteData(value);
notifyListeners();
await loadItems();
}
/// Case-insensitive match against title, subtitle, and category.
List<Item> search(String query) {
final needle = query.trim().toLowerCase();
if (needle.isEmpty) return items;
return _items.where((item) {
return item.title.toLowerCase().contains(needle) ||
item.subtitle.toLowerCase().contains(needle) ||
item.category.label.toLowerCase().contains(needle);
}).toList(growable: false);
}
Item addItem({
required String title,
required String subtitle,
required String description,
required ItemCategory category,
}) {
final created = _repository.createLocal(
title: title,
subtitle: subtitle,
description: description,
category: category,
);
_items = [created, ..._items];
notifyListeners();
return created;
}
Item? findById(String id) {
for (final item in _items) {
if (item.id == id) return item;
}
return null;
}
}
+26 -11
View File
@@ -4,12 +4,11 @@ library;
import 'package:flutter/material.dart';
import '../services/storage_service.dart';
import '../utils/helpers.dart';
/// Provides [themeMode] to [MaterialApp] and writes changes to storage.
class ThemeController extends ChangeNotifier {
ThemeController(this._storage) {
_themeMode = themeModeFromName(_storage.themeModeName);
_themeMode = _fromName(_storage.themeModeName);
}
final StorageService _storage;
@@ -17,21 +16,37 @@ class ThemeController extends ChangeNotifier {
ThemeMode get themeMode => _themeMode;
bool get isDark => _themeMode == ThemeMode.dark;
bool get isDark =>
_themeMode == ThemeMode.dark ||
(_themeMode == ThemeMode.system &&
WidgetsBinding.instance.platformDispatcher.platformBrightness ==
Brightness.dark);
Future<void> setThemeMode(ThemeMode mode) async {
if (mode == _themeMode) return;
_themeMode = mode;
notifyListeners();
await _storage.setThemeModeName(nameFromThemeMode(mode));
await _storage.setThemeModeName(_toName(mode));
}
Future<void> cycleThemeMode() {
final next = switch (_themeMode) {
ThemeMode.system => ThemeMode.light,
ThemeMode.light => ThemeMode.dark,
ThemeMode.dark => ThemeMode.system,
};
return setThemeMode(next);
/// Toggles between light and dark (moon / sun control).
Future<void> toggleLightDark() {
return setThemeMode(isDark ? ThemeMode.light : ThemeMode.dark);
}
}
ThemeMode _fromName(String name) {
return switch (name) {
'dark' => ThemeMode.dark,
'system' => ThemeMode.system,
_ => ThemeMode.light,
};
}
String _toName(ThemeMode mode) {
return switch (mode) {
ThemeMode.light => 'light',
ThemeMode.dark => 'dark',
ThemeMode.system => 'system',
};
}
+455
View File
@@ -0,0 +1,455 @@
/// 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();
}
}