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
+17
View File
@@ -0,0 +1,17 @@
/// Loads the bundled values deck from assets.
library;
import 'dart:convert';
import 'package:flutter/services.dart';
import '../models/value_card.dart';
/// Reads `assets/data/values_deck.json` and returns shuffled-ready cards.
Future<List<ValueCard>> loadValuesDeck() async {
final raw = await rootBundle.loadString('assets/data/values_deck.json');
final list = jsonDecode(raw) as List<dynamic>;
return list
.map((e) => ValueCard.fromJson(e as Map<String, dynamic>))
.toList(growable: false);
}
+6 -21
View File
@@ -1,13 +1,12 @@
/// Application entry point: wires services, Provider, and [MaterialApp].
/// Application entry point: wires Provider and the Values Sort practice.
library;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'providers/item_provider.dart';
import 'providers/theme_provider.dart';
import 'providers/values_sort_provider.dart';
import 'screens/splash_screen.dart';
import 'services/item_repository.dart';
import 'services/storage_service.dart';
import 'utils/app_theme.dart';
import 'utils/constants.dart';
@@ -18,25 +17,14 @@ Future<void> main() async {
final storage = StorageService();
await storage.init();
runApp(
MyApp(
storage: storage,
repository: ItemRepository(useRemoteData: storage.useRemoteData),
),
);
runApp(MyApp(storage: storage));
}
/// Root widget. Theme and catalog controllers live here so every route
/// can `context.watch` them.
/// Root widget. Theme and sort session live here so every route can watch them.
class MyApp extends StatelessWidget {
const MyApp({
super.key,
required this.storage,
required this.repository,
});
const MyApp({super.key, required this.storage});
final StorageService storage;
final ItemRepository repository;
@override
Widget build(BuildContext context) {
@@ -44,10 +32,7 @@ class MyApp extends StatelessWidget {
providers: [
ChangeNotifierProvider(create: (_) => ThemeController(storage)),
ChangeNotifierProvider(
create: (_) => ItemController(
repository: repository,
storage: storage,
)..loadItems(),
create: (_) => ValuesSortController(storage: storage)..load(),
),
],
child: const _MaterialRoot(),
-130
View File
@@ -1,130 +0,0 @@
/// Domain models for catalog items shown in lists and detail screens.
library;
import 'package:flutter/material.dart';
/// High-level grouping used for filters, icons, and accent colors.
enum ItemCategory {
design,
development,
productivity,
lifestyle,
}
extension ItemCategoryX on ItemCategory {
String get label => switch (this) {
ItemCategory.design => 'Design',
ItemCategory.development => 'Development',
ItemCategory.productivity => 'Productivity',
ItemCategory.lifestyle => 'Lifestyle',
};
IconData get icon => switch (this) {
ItemCategory.design => Icons.palette_outlined,
ItemCategory.development => Icons.code,
ItemCategory.productivity => Icons.bolt_outlined,
ItemCategory.lifestyle => Icons.spa_outlined,
};
/// Stable accent used by [Hero] tiles so the animation has a colored target.
Color get accent => switch (this) {
ItemCategory.design => const Color(0xFF7C3AED),
ItemCategory.development => const Color(0xFF0F766E),
ItemCategory.productivity => const Color(0xFFC2410C),
ItemCategory.lifestyle => const Color(0xFF0369A1),
};
static ItemCategory fromName(String name) {
return ItemCategory.values.firstWhere(
(category) => category.name == name,
orElse: () => ItemCategory.productivity,
);
}
static ItemCategory fromIndex(int index) {
return ItemCategory.values[index.abs() % ItemCategory.values.length];
}
}
/// A catalog entry displayed on Home / Search and opened on the detail route.
class Item {
const Item({
required this.id,
required this.title,
required this.subtitle,
required this.description,
required this.category,
required this.createdAt,
});
final String id;
final String title;
final String subtitle;
final String description;
final ItemCategory category;
final DateTime createdAt;
/// Tag used by the list-to-detail [Hero] animation.
String get heroTag => 'item-hero-$id';
Item copyWith({
String? id,
String? title,
String? subtitle,
String? description,
ItemCategory? category,
DateTime? createdAt,
}) {
return Item(
id: id ?? this.id,
title: title ?? this.title,
subtitle: subtitle ?? this.subtitle,
description: description ?? this.description,
category: category ?? this.category,
createdAt: createdAt ?? this.createdAt,
);
}
/// Maps a JSONPlaceholder `/posts` payload (or any similar JSON object).
factory Item.fromJson(Map<String, dynamic> json) {
final id = json['id']?.toString() ?? '0';
final title = (json['title'] as String? ?? 'Untitled').trim();
final body = (json['body'] as String? ?? '').trim();
final numericId = int.tryParse(id) ?? 0;
return Item(
id: 'remote-$id',
title: _titleCase(title),
subtitle: _firstSentence(body),
description: body.replaceAll('\n', ' '),
category: ItemCategoryX.fromIndex(numericId),
createdAt: DateTime(2026, 1, 1).add(Duration(days: numericId)),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'subtitle': subtitle,
'description': description,
'category': category.name,
'createdAt': createdAt.toIso8601String(),
};
}
static String _titleCase(String value) {
if (value.isEmpty) return value;
return value[0].toUpperCase() + value.substring(1);
}
static String _firstSentence(String value) {
if (value.isEmpty) return 'No summary available.';
final cleaned = value.replaceAll('\n', ' ');
final period = cleaned.indexOf('.');
if (period <= 0) {
return cleaned.length > 80 ? '${cleaned.substring(0, 77)}…' : cleaned;
}
return cleaned.substring(0, period + 1);
}
}
+33
View File
@@ -0,0 +1,33 @@
/// A single values-sort card with title, description, and optional domain.
library;
/// Immutable card drawn from the values deck.
class ValueCard {
const ValueCard({
required this.id,
required this.title,
required this.description,
this.domain = 'Values',
});
final String id;
final String title;
final String description;
final String domain;
factory ValueCard.fromJson(Map<String, dynamic> json) {
return ValueCard(
id: json['id'] as String,
title: json['title'] as String,
description: json['description'] as String? ?? '',
domain: json['domain'] as String? ?? 'Values',
);
}
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'description': description,
'domain': domain,
};
}
+68
View File
@@ -0,0 +1,68 @@
/// The five reflective piles used during the initial card sort.
library;
import 'package:flutter/material.dart';
/// Pile ranks from most to least important (design labels 5→1).
enum ValuePile {
mostImportant,
veryImportant,
important,
somewhatImportant,
notImportant,
}
extension ValuePileX on ValuePile {
/// Short UI label (e.g. "Most Important").
String get label => switch (this) {
ValuePile.mostImportant => 'Most Important',
ValuePile.veryImportant => 'Very Important',
ValuePile.important => 'Important',
ValuePile.somewhatImportant => 'Somewhat Important',
ValuePile.notImportant => 'Not Important',
};
/// Compact chip label used in filters and progress bars.
String get shortLabel => switch (this) {
ValuePile.mostImportant => 'Most',
ValuePile.veryImportant => 'Very',
ValuePile.important => 'Important',
ValuePile.somewhatImportant => 'Somewhat',
ValuePile.notImportant => 'Not',
};
/// Supporting line under pile buttons on the sort screen.
String get subtitle => switch (this) {
ValuePile.mostImportant => 'Non-negotiable core',
ValuePile.veryImportant => 'Deeply held guide',
ValuePile.important => 'Valued aspect',
ValuePile.somewhatImportant => 'Contextual',
ValuePile.notImportant => 'Not a current compass',
};
/// Numeric rank shown in the Stitch designs (5 = most).
int get rank => switch (this) {
ValuePile.mostImportant => 5,
ValuePile.veryImportant => 4,
ValuePile.important => 3,
ValuePile.somewhatImportant => 2,
ValuePile.notImportant => 1,
};
/// Accent color for this pile.
Color get accent => switch (this) {
ValuePile.mostImportant => const Color(0xFFC45C3E),
ValuePile.veryImportant => const Color(0xFFC87D55),
ValuePile.important => const Color(0xFF6B8168),
ValuePile.somewhatImportant => const Color(0xFF5B7A8C),
ValuePile.notImportant => const Color(0xFF8A847C),
};
static ValuePile? fromName(String? name) {
if (name == null) return null;
for (final pile in ValuePile.values) {
if (pile.name == name) return pile;
}
return null;
}
}
-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();
}
}
-142
View File
@@ -1,142 +0,0 @@
/// Validated form that inserts a new [Item] into [ItemController].
library;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/item.dart';
import '../providers/item_provider.dart';
import '../widgets/app_text_field.dart';
import '../widgets/responsive_body.dart';
class AddItemScreen extends StatefulWidget {
const AddItemScreen({super.key});
@override
State<AddItemScreen> createState() => _AddItemScreenState();
}
class _AddItemScreenState extends State<AddItemScreen> {
final _formKey = GlobalKey<FormState>();
final _title = TextEditingController();
final _subtitle = TextEditingController();
final _description = TextEditingController();
ItemCategory? _category;
bool _submitted = false;
@override
void dispose() {
_title.dispose();
_subtitle.dispose();
_description.dispose();
super.dispose();
}
String? _requiredLength(String? value, {required int min, String? label}) {
final text = value?.trim() ?? '';
if (text.isEmpty) return 'Enter a ${label ?? 'value'}.';
if (text.length < min) {
return '${label ?? 'This field'} must be at least $min characters.';
}
return null;
}
void _submit() {
setState(() => _submitted = true);
final form = _formKey.currentState;
if (form == null || !form.validate() || _category == null) return;
context.read<ItemController>().addItem(
title: _title.text,
subtitle: _subtitle.text,
description: _description.text,
category: _category!,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Added “${_title.text.trim()}”')),
);
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text('Add item')),
body: ResponsiveBody(
child: Form(
key: _formKey,
autovalidateMode: _submitted
? AutovalidateMode.onUserInteraction
: AutovalidateMode.disabled,
child: ListView(
children: [
const SizedBox(height: 8),
Text(
'Create a catalog entry. All fields are required.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 20),
AppTextField(
controller: _title,
label: 'Title',
hint: 'Give it a short name',
autofocus: true,
textInputAction: TextInputAction.next,
validator: (value) =>
_requiredLength(value, min: 3, label: 'Title'),
),
const SizedBox(height: 16),
AppTextField(
controller: _subtitle,
label: 'Summary',
hint: 'One sentence overview',
textInputAction: TextInputAction.next,
validator: (value) =>
_requiredLength(value, min: 8, label: 'Summary'),
),
const SizedBox(height: 16),
DropdownButtonFormField<ItemCategory>(
initialValue: _category,
decoration: const InputDecoration(labelText: 'Category'),
items: [
for (final category in ItemCategory.values)
DropdownMenuItem(
value: category,
child: Text(category.label),
),
],
onChanged: (value) => setState(() => _category = value),
validator: (value) =>
value == null ? 'Choose a category.' : null,
),
const SizedBox(height: 16),
AppTextField(
controller: _description,
label: 'Description',
hint: 'A longer note the detail screen will show',
maxLines: 5,
keyboardType: TextInputType.multiline,
validator: (value) =>
_requiredLength(value, min: 16, label: 'Description'),
),
const SizedBox(height: 24),
FilledButton(
onPressed: _submit,
child: const Text('Save item'),
),
const SizedBox(height: 12),
OutlinedButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
const SizedBox(height: 24),
],
),
),
),
);
}
}
+190
View File
@@ -0,0 +1,190 @@
/// Stage 1 — assign each value card into one of five reflective piles.
library;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/value_pile.dart';
import '../providers/values_sort_provider.dart';
import '../widgets/practice_chrome.dart';
import '../widgets/value_card_tile.dart';
class CardSortingScreen extends StatelessWidget {
const CardSortingScreen({super.key});
@override
Widget build(BuildContext context) {
final sort = context.watch<ValuesSortController>();
final text = Theme.of(context).textTheme;
final colors = Theme.of(context).colorScheme;
final card = sort.currentCard;
return PracticeScaffold(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 8),
header: Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 0),
child: Row(
children: [
TextButton.icon(
onPressed: sort.sortedCount == 0 ? null : sort.undoLastSort,
icon: const Icon(Icons.undo, size: 18),
label: const Text('Undo'),
),
const Spacer(),
Text(
'Stage 1 · Initial Sort',
style: text.labelMedium,
),
],
),
),
child: Column(
children: [
Row(
children: [
Icon(Icons.bookmark_border, size: 18, color: colors.onSurfaceVariant),
const SizedBox(width: 6),
Expanded(
child: Text(
'${sort.sortedCount} of ${sort.totalCount} values sorted',
style: text.labelMedium,
),
),
Text(
'${(sort.sortProgress * 100).round()}%',
style: text.labelMedium?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
value: sort.sortProgress,
minHeight: 6,
backgroundColor: colors.surfaceContainerHigh,
color: colors.secondary,
),
),
const SizedBox(height: 12),
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 280),
child: card == null
? Center(
key: const ValueKey('empty'),
child: Text('All sorted', style: text.headlineMedium),
)
: SingleChildScrollView(
key: ValueKey(card.id),
child: Column(
children: [
ValueCardTile(card: card, elevation: 2),
const SizedBox(height: 14),
Text(
'Tap a pile to sort',
style: text.labelMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 8),
...ValuePile.values.map((pile) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _PileButton(
pile: pile,
count: sort.countInPile(pile),
onTap: () => sort.assignCurrent(pile),
),
);
}),
],
),
),
),
),
],
),
);
}
}
class _PileButton extends StatelessWidget {
const _PileButton({
required this.pile,
required this.count,
required this.onTap,
});
final ValuePile pile;
final int count;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final text = Theme.of(context).textTheme;
final colors = Theme.of(context).colorScheme;
return Material(
color: colors.surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
side: BorderSide(color: pile.accent.withValues(alpha: 0.35)),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
children: [
Container(
width: 28,
height: 28,
alignment: Alignment.center,
decoration: BoxDecoration(
color: pile.accent.withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: Text(
'${pile.rank}',
style: text.labelMedium?.copyWith(
color: pile.accent,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
pile.label,
style: text.titleMedium?.copyWith(
color: colors.onSurface,
),
),
Text(
pile.subtitle,
style: text.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
),
),
Text(
'$count',
style: text.labelMedium?.copyWith(color: pile.accent),
),
],
),
),
),
);
}
}
-212
View File
@@ -1,212 +0,0 @@
/// Home tab: greeting, stats, and a [ListView.builder] of catalog items.
library;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/item_provider.dart';
import '../widgets/empty_state.dart';
import '../widgets/item_card.dart';
import '../widgets/loading_view.dart';
import '../widgets/responsive_body.dart';
import 'add_item_screen.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
final controller = context.watch<ItemController>();
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text('Home')),
floatingActionButton: FloatingActionButton.extended(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const AddItemScreen()),
);
},
icon: const Icon(Icons.add),
label: const Text('Add item'),
),
body: RefreshIndicator(
onRefresh: controller.loadItems,
child: ResponsiveBody(
child: _HomeBody(controller: controller, theme: theme),
),
),
);
}
}
class _HomeBody extends StatelessWidget {
const _HomeBody({
required this.controller,
required this.theme,
});
final ItemController controller;
final ThemeData theme;
@override
Widget build(BuildContext context) {
if (controller.isLoading && controller.items.isEmpty) {
return const LoadingView(message: 'Fetching catalog…');
}
if (controller.error != null && controller.items.isEmpty) {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
EmptyState(
icon: Icons.error_outline,
title: 'Something went wrong',
message: controller.error!,
actionLabel: 'Retry',
onAction: controller.loadItems,
),
],
);
}
if (controller.items.isEmpty) {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
EmptyState(
icon: Icons.inbox_outlined,
title: 'No items yet',
message: 'Add your first item with the button below.',
),
],
);
}
// Header occupies the first slot; remaining slots are catalog cards.
return ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(top: 8, bottom: 96),
itemCount: controller.items.length + 1,
itemBuilder: (context, index) {
if (index == 0) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_WelcomeBanner(itemCount: controller.count),
const SizedBox(height: 20),
Text('Featured items', style: theme.textTheme.titleMedium),
const SizedBox(height: 12),
],
);
}
final item = controller.items[index - 1];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: ItemCard(item: item, heroScope: 'home'),
);
},
);
}
}
class _WelcomeBanner extends StatelessWidget {
const _WelcomeBanner({required this.itemCount});
final int itemCount;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
colors: [colors.primary, colors.tertiary],
),
boxShadow: [
BoxShadow(
color: colors.primary.withValues(alpha: 0.28),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Welcome back',
style: theme.textTheme.labelMedium?.copyWith(
color: colors.onPrimary.withValues(alpha: 0.8),
),
),
const SizedBox(height: 4),
Text(
'Your catalog at a glance',
style: theme.textTheme.headlineMedium?.copyWith(
color: colors.onPrimary,
),
),
const SizedBox(height: 16),
Row(
children: [
_StatPill(
label: 'Items',
value: '$itemCount',
icon: Icons.collections_bookmark_outlined,
),
const SizedBox(width: 10),
const _StatPill(
label: 'Theme',
value: 'M3',
icon: Icons.palette_outlined,
),
],
),
],
),
);
}
}
class _StatPill extends StatelessWidget {
const _StatPill({
required this.label,
required this.value,
required this.icon,
});
final String label;
final String value;
final IconData icon;
@override
Widget build(BuildContext context) {
final onPrimary = Theme.of(context).colorScheme.onPrimary;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: onPrimary.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(icon, size: 16, color: onPrimary),
const SizedBox(width: 8),
Text(
'$value $label',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: onPrimary,
),
),
],
),
);
}
}
-82
View File
@@ -1,82 +0,0 @@
/// Detail route opened from a list card, with a [Hero] matching the list badge.
library;
import 'package:flutter/material.dart';
import '../models/item.dart';
import '../utils/helpers.dart';
import '../widgets/item_hero_badge.dart';
import '../widgets/responsive_body.dart';
class ItemDetailScreen extends StatelessWidget {
const ItemDetailScreen({
super.key,
required this.item,
required this.heroTag,
});
final Item item;
final String heroTag;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('Details')),
body: ResponsiveBody(
child: ListView(
children: [
const SizedBox(height: 16),
Center(
child: ItemHeroBadge(item: item, size: 96, heroTag: heroTag),
),
const SizedBox(height: 24),
Text(item.title, style: theme.textTheme.headlineMedium),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
Chip(
avatar: Icon(
item.category.icon,
size: 16,
color: item.category.accent,
),
label: Text(item.category.label),
side: BorderSide(
color: item.category.accent.withValues(alpha: 0.4),
),
),
Chip(
avatar: Icon(
Icons.calendar_today_outlined,
size: 16,
color: colors.onSurfaceVariant,
),
label: Text(formatDate(item.createdAt)),
),
],
),
const SizedBox(height: 20),
Text('Summary', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text(item.subtitle, style: theme.textTheme.bodyLarge),
const SizedBox(height: 20),
Text('Description', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text(item.description, style: theme.textTheme.bodyLarge),
const SizedBox(height: 32),
FilledButton.tonal(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Back to list'),
),
const SizedBox(height: 24),
],
),
),
);
}
}
-64
View File
@@ -1,64 +0,0 @@
/// Host scaffold that owns the bottom navigation bar and its four tabs.
library;
import 'package:flutter/material.dart';
import 'home_screen.dart';
import 'profile_screen.dart';
import 'search_screen.dart';
import 'settings_screen.dart';
/// Switches between Home, Search, Profile, and Settings without stacking routes.
class MainShell extends StatefulWidget {
const MainShell({super.key});
@override
State<MainShell> createState() => _MainShellState();
}
class _MainShellState extends State<MainShell> {
int _index = 0;
static const _pages = [
HomeScreen(),
SearchScreen(),
ProfileScreen(),
SettingsScreen(),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _index,
children: _pages,
),
bottomNavigationBar: NavigationBar(
selectedIndex: _index,
onDestinationSelected: (index) => setState(() => _index = index),
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Home',
),
NavigationDestination(
icon: Icon(Icons.search_outlined),
selectedIcon: Icon(Icons.search),
label: 'Search',
),
NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: 'Profile',
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Settings',
),
],
),
);
}
}
+129
View File
@@ -0,0 +1,129 @@
/// Refine a single pile — promote / demote cards (Important Cards pattern).
library;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/value_pile.dart';
import '../providers/values_sort_provider.dart';
import '../widgets/practice_chrome.dart';
import '../widgets/value_card_tile.dart';
import '../utils/app_theme.dart';
class PileReviewScreen extends StatelessWidget {
const PileReviewScreen({super.key});
@override
Widget build(BuildContext context) {
final sort = context.watch<ValuesSortController>();
final pile = sort.reviewPile ?? ValuePile.important;
final cards = sort.cardsInPile(pile);
final text = Theme.of(context).textTheme;
final colors = Theme.of(context).colorScheme;
return PracticeScaffold(
header: Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 12, 0),
child: Row(
children: [
IconButton(
onPressed: sort.goToOverview,
icon: const Icon(Icons.arrow_back),
),
Expanded(
child: Text(
'Reviewing · ${pile.label}',
style: text.labelMedium,
),
),
],
),
),
footer: PrimaryPillButton(
label: 'Back to overview',
onPressed: sort.goToOverview,
),
child: ListView(
children: [
Text(pile.label, style: text.displaySmall),
const SizedBox(height: 6),
Text(
'${cards.length} values in this pile. Promote toward your core or demote when something feels less essential.',
style: text.bodyMedium,
),
const SizedBox(height: 12),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: ValuePile.values.map((p) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ActionChip(
avatar: CircleAvatar(
backgroundColor: p.accent.withValues(alpha: 0.2),
radius: 8,
),
label: Text('${p.shortLabel} ${sort.countInPile(p)}'),
onPressed: () => sort.openPileReview(p),
backgroundColor: p == pile
? AppTheme.surfaceContainerHigh
: AppTheme.surfaceContainerLow,
),
);
}).toList(),
),
),
const SizedBox(height: 16),
if (cards.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 40),
child: Text(
'No cards in this pile yet.',
style: text.bodyLarge?.copyWith(color: colors.onSurfaceVariant),
textAlign: TextAlign.center,
),
)
else
...cards.map((card) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: ValueCardTile(
card: card,
compact: true,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Promote',
onPressed: () => _shift(sort, card.id, pile, up: true),
icon: const Icon(Icons.arrow_upward),
),
IconButton(
tooltip: 'Demote',
onPressed: () =>
_shift(sort, card.id, pile, up: false),
icon: const Icon(Icons.arrow_downward),
),
],
),
),
);
}),
],
),
);
}
void _shift(
ValuesSortController sort,
String id,
ValuePile current, {
required bool up,
}) {
final values = ValuePile.values;
final index = values.indexOf(current);
final next = up ? index - 1 : index + 1;
if (next < 0 || next >= values.length) return;
sort.moveCard(id, values[next]);
}
}
+66
View File
@@ -0,0 +1,66 @@
/// Host that swaps practice stages from [ValuesSortController].
library;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/values_sort_provider.dart';
import 'card_sorting_screen.dart';
import 'pile_review_screen.dart';
import 'practice_hub_screen.dart';
import 'rank_order_screen.dart';
import 'reflection_screen.dart';
import 'sorted_overview_screen.dart';
import 'welcome_screen.dart';
class PracticeFlowScreen extends StatelessWidget {
const PracticeFlowScreen({super.key});
@override
Widget build(BuildContext context) {
final sort = context.watch<ValuesSortController>();
if (sort.loading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
if (sort.error != null) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Could not load the values deck.\n${sort.error}'),
const SizedBox(height: 12),
FilledButton(
onPressed: sort.load,
child: const Text('Retry'),
),
],
),
),
),
);
}
return AnimatedSwitcher(
duration: const Duration(milliseconds: 320),
child: KeyedSubtree(
key: ValueKey(sort.stage),
child: switch (sort.stage) {
SortStage.welcome => const WelcomeScreen(),
SortStage.sorting => const CardSortingScreen(),
SortStage.hub => const PracticeHubScreen(),
SortStage.overview => const SortedOverviewScreen(),
SortStage.pileReview => const PileReviewScreen(),
SortStage.ranking => const RankOrderScreen(),
SortStage.reflection => const ReflectionScreen(),
},
),
);
}
}
+447
View File
@@ -0,0 +1,447 @@
/// Post-sort home — landscape summary and gateways (Stitch Practice Hub).
library;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/value_card.dart';
import '../providers/values_sort_provider.dart';
import '../widgets/practice_chrome.dart';
class PracticeHubScreen extends StatelessWidget {
const PracticeHubScreen({super.key});
@override
Widget build(BuildContext context) {
final sort = context.watch<ValuesSortController>();
final text = Theme.of(context).textTheme;
final colors = Theme.of(context).colorScheme;
final answered = sort.totalAnsweredReflections;
final slots = sort.totalReflectionSlots;
final progress = slots == 0 ? 0.0 : answered / slots;
final core = sort.rankedCards.take(5).toList(growable: false);
return PracticeScaffold(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 8),
footer: FilledButton.tonal(
onPressed: () => _confirmReset(context, sort),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
backgroundColor: colors.surfaceContainerHigh,
foregroundColor: colors.onSurface,
shape: const StadiumBorder(),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.restart_alt, size: 18, color: colors.outline),
const SizedBox(width: 8),
Text(
'Finish Practice (Reset All)',
style: text.labelLarge?.copyWith(color: colors.onSurface),
),
],
),
),
child: ListView(
children: [
Text(
'Your Values Landscape',
style: text.displaySmall?.copyWith(color: colors.onSurface),
),
const SizedBox(height: 18),
_CoreValuesCard(
values: core,
onValueTap: (card) => sort.goToReflection(valueId: card.id),
),
const SizedBox(height: 20),
_GatewayCard(
icon: Icons.layers_outlined,
iconColor: colors.primary,
title: 'All Sorted Values',
onTap: sort.goToOverview,
),
const SizedBox(height: 12),
_GatewayCard(
icon: Icons.format_list_numbered,
iconColor: colors.secondary,
title: 'Order Top Values',
onTap: sort.goToRanking,
),
const SizedBox(height: 12),
_GatewayCard(
icon: Icons.edit_note,
iconColor: colors.tertiary,
title: 'Reflect & Inquire',
meta: slots == 0
? 'Rank top values first'
: '$answered of $slots Answered (${(progress * 100).round()}%)',
metaBackground: colors.tertiaryContainer.withValues(alpha: 0.45),
metaForeground: colors.onTertiaryContainer,
progress: slots == 0 ? null : progress,
onTap: sort.coreValues.isEmpty ? null : sort.goToReflection,
),
const SizedBox(height: 8),
],
),
);
}
Future<void> _confirmReset(
BuildContext context,
ValuesSortController sort,
) async {
final ok = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
builder: (ctx) {
final text = Theme.of(ctx).textTheme;
final c = Theme.of(ctx).colorScheme;
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
backgroundColor: c.errorContainer.withValues(alpha: 0.5),
child: Icon(Icons.hourglass_empty, color: c.error),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Reset Entire Practice?', style: text.titleLarge),
Text(
'Clean slate contemplation',
style: text.labelSmall,
),
],
),
),
],
),
const SizedBox(height: 14),
Text(
'This deliberate action will clear all 5 categorized piles, '
'your prioritized hierarchy, and stored reflection notes. '
'You will begin this mindful exploration from the initial deck.',
style: text.bodyMedium,
),
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: c.surfaceContainer,
borderRadius: BorderRadius.circular(16),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.lightbulb_outline,
size: 18, color: c.secondary),
const SizedBox(width: 8),
Expanded(
child: Text(
'Tip: You can pause here and revisit your values whenever you need clarity in daily choices without resetting.',
style: text.bodySmall,
),
),
],
),
),
const SizedBox(height: 18),
PrimaryPillButton(
label: 'Yes, Reset All ${sort.totalCount} Cards',
onPressed: () => Navigator.pop(ctx, true),
),
const SizedBox(height: 8),
GhostPillButton(
label: 'Keep My Current Landscape',
onPressed: () => Navigator.pop(ctx, false),
),
],
),
),
);
},
);
if (ok == true) sort.restart();
}
}
class _CoreValuesCard extends StatelessWidget {
const _CoreValuesCard({
required this.values,
required this.onValueTap,
});
final List<ValueCard> values;
final void Function(ValueCard card) onValueTap;
@override
Widget build(BuildContext context) {
final text = Theme.of(context).textTheme;
final colors = Theme.of(context).colorScheme;
final accent = colors.secondary;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colors.surfaceContainerLow,
borderRadius: BorderRadius.circular(22),
border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.7)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.18),
shape: BoxShape.circle,
),
child: Icon(Icons.star, size: 15, color: accent),
),
const SizedBox(width: 10),
Expanded(
child: Text(
'Your Core Values',
style: text.titleLarge?.copyWith(
color: colors.onSurface,
fontWeight: FontWeight.w400,
),
),
),
],
),
const SizedBox(height: 14),
if (values.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(
'Rank your Most Important values to distill your core set here.',
style: text.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
)
else
LayoutBuilder(
builder: (context, constraints) {
final gap = 8.0;
final chipWidth = (constraints.maxWidth - gap) / 2;
return Wrap(
spacing: gap,
runSpacing: gap,
children: [
for (var i = 0; i < values.length; i++)
SizedBox(
width: chipWidth,
child: _CoreValueChip(
rank: i + 1,
title: values[i].title,
accent: accent,
onTap: () => onValueTap(values[i]),
),
),
],
);
},
),
],
),
);
}
}
class _CoreValueChip extends StatelessWidget {
const _CoreValueChip({
required this.rank,
required this.title,
required this.accent,
required this.onTap,
});
final int rank;
final String title;
final Color accent;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final text = Theme.of(context).textTheme;
final colors = Theme.of(context).colorScheme;
return Material(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
child: Row(
children: [
Container(
width: 22,
height: 22,
alignment: Alignment.center,
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.18),
shape: BoxShape.circle,
),
child: Text(
'$rank',
style: text.labelSmall?.copyWith(
color: accent,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: text.labelMedium?.copyWith(
color: colors.onSurface,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
),
);
}
}
class _GatewayCard extends StatelessWidget {
const _GatewayCard({
required this.icon,
required this.iconColor,
required this.title,
required this.onTap,
this.meta,
this.metaBackground,
this.metaForeground,
this.progress,
});
final IconData icon;
final Color iconColor;
final String title;
final String? meta;
final Color? metaBackground;
final Color? metaForeground;
final VoidCallback? onTap;
final double? progress;
@override
Widget build(BuildContext context) {
final text = Theme.of(context).textTheme;
final colors = Theme.of(context).colorScheme;
final enabled = onTap != null;
final hasMeta = meta != null && meta!.isNotEmpty;
return Opacity(
opacity: enabled ? 1 : 0.55,
child: Material(
color: colors.surfaceContainerLow,
elevation: 0,
shadowColor: colors.shadow.withValues(alpha: 0.08),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(20),
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
shape: BoxShape.circle,
),
child: Icon(icon, color: iconColor, size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
title,
style: text.titleLarge?.copyWith(
color: colors.onSurface,
fontWeight: FontWeight.w400,
),
),
if (hasMeta) ...[
const SizedBox(height: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 3,
),
decoration: BoxDecoration(
color: metaBackground ?? colors.surfaceContainer,
borderRadius: BorderRadius.circular(999),
),
child: Text(
meta!,
style: text.labelSmall?.copyWith(
color: metaForeground ?? colors.onSurfaceVariant,
),
),
),
],
],
),
),
Icon(
Icons.arrow_forward,
color: colors.primary,
size: 22,
),
],
),
if (progress != null) ...[
const SizedBox(height: 14),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
value: progress,
minHeight: 6,
backgroundColor: colors.surfaceContainerHighest,
color: colors.tertiary,
),
),
],
],
),
),
),
),
);
}
}
-142
View File
@@ -1,142 +0,0 @@
/// Profile tab: avatar, stats, and a shortcut to the add-item form.
library;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/item_provider.dart';
import '../providers/theme_provider.dart';
import '../utils/constants.dart';
import '../utils/helpers.dart';
import '../widgets/responsive_body.dart';
import 'add_item_screen.dart';
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context) {
final items = context.watch<ItemController>();
final themeController = context.watch<ThemeController>();
final theme = Theme.of(context);
final colors = theme.colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('Profile')),
body: ResponsiveBody(
child: ListView(
children: [
const SizedBox(height: 12),
Center(
child: CircleAvatar(
radius: 44,
backgroundColor: colors.primaryContainer,
child: Icon(
Icons.person,
size: 44,
color: colors.onPrimaryContainer,
),
),
),
const SizedBox(height: 16),
Text(
'Alex Rivera',
textAlign: TextAlign.center,
style: theme.textTheme.headlineMedium,
),
const SizedBox(height: 4),
Text(
'[email protected]',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: _ProfileStat(
label: 'Items',
value: '${items.count}',
),
),
const SizedBox(width: 12),
Expanded(
child: _ProfileStat(
label: 'Theme',
value: labelForThemeMode(themeController.themeMode),
),
),
const SizedBox(width: 12),
Expanded(
child: _ProfileStat(
label: 'Source',
value: items.useRemoteData ? 'Remote' : 'Local',
),
),
],
),
const SizedBox(height: 24),
Card(
child: Column(
children: [
ListTile(
leading: const Icon(Icons.add_box_outlined),
title: const Text('Add a catalog item'),
subtitle: const Text('Validated form with category picker'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => const AddItemScreen(),
),
);
},
),
const Divider(height: 1),
const ListTile(
leading: Icon(Icons.info_outline),
title: Text('About'),
subtitle: Text('$kAppName · $kPackageName'),
),
],
),
),
],
),
),
);
}
}
class _ProfileStat extends StatelessWidget {
const _ProfileStat({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: theme.shadowColor.withValues(alpha: 0.06),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: [
Text(value, style: theme.textTheme.titleLarge),
const SizedBox(height: 4),
Text(label, style: theme.textTheme.labelMedium),
],
),
);
}
}
+191
View File
@@ -0,0 +1,191 @@
/// Rank-order the Most Important nominees into a personal hierarchy.
library;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/values_sort_provider.dart';
import '../widgets/practice_chrome.dart';
class RankOrderScreen extends StatelessWidget {
const RankOrderScreen({super.key});
@override
Widget build(BuildContext context) {
final sort = context.watch<ValuesSortController>();
final ranked = sort.rankedCards;
final text = Theme.of(context).textTheme;
final colors = Theme.of(context).colorScheme;
return PracticeScaffold(
header: Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 12, 0),
child: Row(
children: [
IconButton(
onPressed: sort.goToHub,
icon: const Icon(Icons.arrow_back),
),
Expanded(
child: Text(
'Stage 3 · Prioritization',
style: text.labelMedium,
),
),
],
),
),
footer: PrimaryPillButton(
label: 'Reflect on top values',
icon: Icons.spa_outlined,
onPressed: ranked.isEmpty ? null : sort.goToReflection,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Order Your Core Values', style: text.displaySmall),
const SizedBox(height: 6),
Text(
'Drag or reorder your finalists. Let #1 represent your non-negotiable value.',
style: text.bodyMedium,
),
const SizedBox(height: 12),
if (ranked.isEmpty)
Expanded(
child: Center(
child: Text(
'Promote a few values into Most Important first.',
style: text.bodyLarge?.copyWith(color: colors.onSurfaceVariant),
textAlign: TextAlign.center,
),
),
)
else
Expanded(
child: ReorderableListView.builder(
itemCount: ranked.length,
onReorderItem: sort.reorderRanked,
proxyDecorator: (child, index, animation) {
return Material(
elevation: 6,
color: Colors.transparent,
shadowColor: const Color(0x332C2825),
child: child,
);
},
itemBuilder: (context, index) {
final card = ranked[index];
final isPrimary = index == 0;
return Container(
key: ValueKey(card.id),
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.fromLTRB(12, 12, 4, 12),
decoration: BoxDecoration(
color: isPrimary
? colors.surfaceContainerLowest
: colors.surfaceContainerLow,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isPrimary
? colors.secondary.withValues(alpha: 0.5)
: colors.outlineVariant,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 36,
child: Column(
children: [
Text(
'${index + 1}',
style: text.titleLarge?.copyWith(
color: isPrimary
? colors.secondary
: colors.primary,
),
),
if (isPrimary)
Icon(
Icons.star,
size: 14,
color: colors.secondary,
),
],
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
card.title,
style: text.titleLarge?.copyWith(
color: colors.onSurface,
),
),
const SizedBox(height: 4),
Text(
card.description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: text.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
'Pillar: ${card.domain}',
style: text.labelSmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
),
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 28,
),
onPressed: () => sort.moveRank(index, up: true),
icon: const Icon(Icons.keyboard_arrow_up, size: 22),
),
ReorderableDragStartListener(
index: index,
child: const Padding(
padding: EdgeInsets.symmetric(vertical: 2),
child: Icon(Icons.drag_indicator, size: 22),
),
),
IconButton(
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 28,
),
onPressed: () => sort.moveRank(index, up: false),
icon: const Icon(Icons.keyboard_arrow_down, size: 22),
),
],
),
],
),
);
},
),
),
],
),
);
}
}
+298
View File
@@ -0,0 +1,298 @@
/// Deep reflection on top values, with optional full-screen journal sheet.
library;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/values_sort_provider.dart';
import '../widgets/practice_chrome.dart';
class ReflectionScreen extends StatelessWidget {
const ReflectionScreen({super.key});
@override
Widget build(BuildContext context) {
final sort = context.watch<ValuesSortController>();
final cores = sort.coreValues;
final activeId = sort.activeReflectionValueId ??
(cores.isEmpty ? null : cores.first.id);
final active = activeId == null ? null : sort.cardById(activeId);
final text = Theme.of(context).textTheme;
final colors = Theme.of(context).colorScheme;
return PracticeScaffold(
header: Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 12, 0),
child: Row(
children: [
IconButton(
onPressed: sort.goToHub,
icon: const Icon(Icons.arrow_back),
),
Expanded(
child: Text(
'Step 5 · Deep Reflection',
style: text.labelMedium,
),
),
],
),
),
child: cores.isEmpty || active == null
? Center(
child: Text(
'Rank at least one core value to reflect.',
style: text.bodyLarge,
),
)
: ListView(
children: [
Text('Your Core Values', style: text.displaySmall),
const SizedBox(height: 4),
Text(
'${cores.length} core anchors · '
'${sort.totalAnsweredReflections} of ${sort.totalReflectionSlots} answered',
style: text.bodyMedium,
),
const SizedBox(height: 14),
SizedBox(
height: 88,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: cores.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final card = cores[index];
final selected = card.id == active.id;
final answered = sort.answeredPromptCount(card.id);
final rankColor =
selected ? colors.onPrimary : colors.secondary;
final titleColor =
selected ? colors.onPrimary : colors.onSurface;
final metaColor = selected
? colors.onPrimary.withValues(alpha: 0.85)
: colors.onSurfaceVariant;
return InkWell(
onTap: () => sort.selectReflectionValue(card.id),
borderRadius: BorderRadius.circular(16),
child: Container(
width: 120,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: selected
? colors.primary
: colors.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: selected
? colors.primary
: colors.outlineVariant,
width: selected ? 2 : 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'#${index + 1}',
style: text.labelSmall?.copyWith(
color: rankColor,
fontWeight: FontWeight.w700,
),
),
Text(
card.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: text.titleMedium?.copyWith(
color: titleColor,
fontWeight: selected
? FontWeight.w700
: FontWeight.w500,
),
),
Text(
'$answered/${kReflectionPrompts.length}',
style: text.labelSmall?.copyWith(
color: metaColor,
),
),
],
),
),
);
},
),
),
const SizedBox(height: 18),
Text(active.title, style: text.headlineMedium),
const SizedBox(height: 4),
Text(
'"${active.description}"',
style: text.bodyMedium?.copyWith(fontStyle: FontStyle.italic),
),
const SizedBox(height: 16),
...List.generate(kReflectionPrompts.length, (index) {
final answer = sort.reflectionFor(active.id, index);
final answered = answer.isNotEmpty;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Material(
color: colors.surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
side: BorderSide(color: colors.outlineVariant),
),
child: ListTile(
leading: Icon(
answered ? Icons.check_circle : Icons.circle_outlined,
color: answered
? colors.tertiary
: colors.onSurfaceVariant,
),
title: Text(
kReflectionPrompts[index],
style: text.titleMedium,
),
subtitle: answered
? Text(
answer,
maxLines: 2,
overflow: TextOverflow.ellipsis,
)
: null,
trailing: Icon(
answered ? Icons.edit_outlined : Icons.open_in_full,
),
onTap: () => _openJournal(
context,
sort,
active.id,
index,
),
),
),
);
}),
],
),
);
}
Future<void> _openJournal(
BuildContext context,
ValuesSortController sort,
String valueId,
int promptIndex,
) async {
sort.selectPrompt(promptIndex);
final card = sort.cardById(valueId);
if (card == null) return;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (ctx) => _ReflectionJournalSheet(
cardTitle: card.title,
prompt: kReflectionPrompts[promptIndex],
initialText: sort.reflectionFor(valueId, promptIndex),
onSave: (text) {
sort.setReflection(valueId, promptIndex, text);
},
),
);
}
}
class _ReflectionJournalSheet extends StatefulWidget {
const _ReflectionJournalSheet({
required this.cardTitle,
required this.prompt,
required this.initialText,
required this.onSave,
});
final String cardTitle;
final String prompt;
final String initialText;
final ValueChanged<String> onSave;
@override
State<_ReflectionJournalSheet> createState() =>
_ReflectionJournalSheetState();
}
class _ReflectionJournalSheetState extends State<_ReflectionJournalSheet> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialText);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _saveAndClose() {
widget.onSave(_controller.text);
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final text = Theme.of(context).textTheme;
final colors = Theme.of(context).colorScheme;
final bottom = MediaQuery.viewInsetsOf(context).bottom;
return Padding(
padding: EdgeInsets.fromLTRB(20, 8, 20, bottom + 20),
child: Column(
children: [
Row(
children: [
Expanded(
child: Text(
'Reflection · ${widget.cardTitle}',
style: text.titleLarge,
),
),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close),
),
],
),
Align(
alignment: Alignment.centerLeft,
child: Text(
widget.prompt,
style: text.bodyLarge?.copyWith(color: colors.onSurfaceVariant),
),
),
const SizedBox(height: 12),
Expanded(
child: TextField(
controller: _controller,
expands: true,
maxLines: null,
textAlignVertical: TextAlignVertical.top,
decoration: const InputDecoration(
hintText: 'Write freely. This stays on your device.',
),
),
),
const SizedBox(height: 12),
PrimaryPillButton(
label: 'Save reflection',
onPressed: _saveAndClose,
),
],
),
);
}
}
-104
View File
@@ -1,104 +0,0 @@
/// Search tab: query field plus a [ListView.builder] of matching items.
library;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/item.dart';
import '../providers/item_provider.dart';
import '../widgets/empty_state.dart';
import '../widgets/item_card.dart';
import '../widgets/responsive_body.dart';
class SearchScreen extends StatefulWidget {
const SearchScreen({super.key});
@override
State<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends State<SearchScreen> {
final _query = TextEditingController();
@override
void dispose() {
_query.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final results = context.watch<ItemController>().search(_query.text);
return Scaffold(
appBar: AppBar(title: const Text('Search')),
body: ResponsiveBody(
child: Column(
children: [
const SizedBox(height: 8),
TextField(
controller: _query,
textInputAction: TextInputAction.search,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
hintText: 'Title, summary, or category',
prefixIcon: const Icon(Icons.search),
suffixIcon: _query.text.isEmpty
? null
: IconButton(
tooltip: 'Clear',
onPressed: () {
_query.clear();
setState(() {});
},
icon: const Icon(Icons.close),
),
),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: Text(
'${results.length} result${results.length == 1 ? '' : 's'}',
style: Theme.of(context).textTheme.labelMedium,
),
),
const SizedBox(height: 8),
Expanded(child: _ResultsList(results: results, query: _query.text)),
],
),
),
);
}
}
class _ResultsList extends StatelessWidget {
const _ResultsList({required this.results, required this.query});
final List<Item> results;
final String query;
@override
Widget build(BuildContext context) {
if (results.isEmpty) {
return EmptyState(
icon: Icons.search_off,
title: query.trim().isEmpty ? 'Start typing' : 'No matches',
message: query.trim().isEmpty
? 'Search the catalog by title, summary, or category.'
: 'Try a different keyword.',
);
}
return ListView.builder(
padding: const EdgeInsets.only(bottom: 24),
itemCount: results.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: ItemCard(item: results[index], heroScope: 'search'),
);
},
);
}
}
-104
View File
@@ -1,104 +0,0 @@
/// Settings tab: theme mode, data source, and about information.
library;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/item_provider.dart';
import '../providers/theme_provider.dart';
import '../utils/constants.dart';
import '../utils/helpers.dart';
import '../widgets/responsive_body.dart';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context) {
final themeController = context.watch<ThemeController>();
final items = context.watch<ItemController>();
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: ResponsiveBody(
child: ListView(
children: [
const SizedBox(height: 8),
Text('Appearance', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text(
'Theme is saved with shared_preferences and restored on launch.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
SegmentedButton<ThemeMode>(
segments: const [
ButtonSegment(
value: ThemeMode.system,
label: Text('System'),
icon: Icon(Icons.brightness_auto),
),
ButtonSegment(
value: ThemeMode.light,
label: Text('Light'),
icon: Icon(Icons.light_mode_outlined),
),
ButtonSegment(
value: ThemeMode.dark,
label: Text('Dark'),
icon: Icon(Icons.dark_mode_outlined),
),
],
selected: {themeController.themeMode},
onSelectionChanged: (selection) {
themeController.setThemeMode(selection.first);
},
),
const SizedBox(height: 28),
Text('Data', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Card(
child: SwitchListTile(
title: const Text('Use remote sample API'),
subtitle: const Text(
'When on, items are fetched with the http package from '
'JSONPlaceholder. Failures fall back to local mock data.',
),
value: items.useRemoteData,
onChanged: (value) => items.setUseRemoteData(value),
),
),
const SizedBox(height: 28),
Text('About', style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Card(
child: Column(
children: [
const ListTile(
leading: Icon(Icons.apps),
title: Text(kAppName),
subtitle: Text('Generic Flutter Android starter'),
),
const Divider(height: 1),
const ListTile(
leading: Icon(Icons.fingerprint),
title: Text('Package'),
subtitle: Text(kPackageName),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.contrast),
title: const Text('Active theme'),
subtitle: Text(labelForThemeMode(themeController.themeMode)),
),
],
),
),
const SizedBox(height: 24),
],
),
),
);
}
}
+289
View File
@@ -0,0 +1,289 @@
/// Bird's-eye overview of all five piles with demote / refine actions.
library;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/value_card.dart';
import '../models/value_pile.dart';
import '../providers/values_sort_provider.dart';
import '../widgets/practice_chrome.dart';
import '../widgets/value_card_tile.dart';
class SortedOverviewScreen extends StatefulWidget {
const SortedOverviewScreen({super.key});
@override
State<SortedOverviewScreen> createState() => _SortedOverviewScreenState();
}
class _SortedOverviewScreenState extends State<SortedOverviewScreen> {
String _query = '';
ValuePile? _filter;
@override
Widget build(BuildContext context) {
final sort = context.watch<ValuesSortController>();
final text = Theme.of(context).textTheme;
final colors = Theme.of(context).colorScheme;
return PracticeScaffold(
header: Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 12, 0),
child: Row(
children: [
IconButton(
onPressed: sort.goToHub,
icon: const Icon(Icons.arrow_back),
),
Expanded(
child: Text(
'All Sorted Values',
style: text.labelMedium,
),
),
],
),
),
child: ListView(
children: [
Text('All Sorted Values', style: text.displaySmall),
const SizedBox(height: 16),
TextField(
onChanged: (v) => setState(() => _query = v.trim().toLowerCase()),
decoration: const InputDecoration(
hintText: 'Find a value (e.g., Creativity)',
prefixIcon: Icon(Icons.search),
),
),
const SizedBox(height: 10),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_FilterChip(
label: 'All (${sort.totalCount})',
selected: _filter == null,
onTap: () => setState(() => _filter = null),
),
...ValuePile.values.map((pile) {
return _FilterChip(
label: '${pile.shortLabel} (${sort.countInPile(pile)})',
selected: _filter == pile,
accent: pile.accent,
onTap: () => setState(() => _filter = pile),
);
}),
],
),
),
const SizedBox(height: 18),
...ValuePile.values
.where((p) => _filter == null || _filter == p)
.map((pile) {
final cards = sort.cardsInPile(pile).where((c) {
if (_query.isEmpty) return true;
return c.title.toLowerCase().contains(_query) ||
c.description.toLowerCase().contains(_query);
}).toList();
return _PileSection(
pile: pile,
cards: cards,
onDemote: (card) => _showDemoteSheet(context, sort, card),
);
}),
const SizedBox(height: 12),
Text(
'Tip: refine any pile before ranking your core values.',
style: text.bodySmall?.copyWith(color: colors.onSurfaceVariant),
textAlign: TextAlign.center,
),
],
),
);
}
Future<void> _showDemoteSheet(
BuildContext context,
ValuesSortController sort,
ValueCard card,
) async {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (ctx) {
final text = Theme.of(ctx).textTheme;
final colors = Theme.of(ctx).colorScheme;
final maxHeight = MediaQuery.sizeOf(ctx).height * 0.75;
return SafeArea(
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxHeight),
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
24,
8,
24,
MediaQuery.paddingOf(ctx).bottom + 24,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
'Reorder ${card.title}',
style: text.titleLarge,
),
),
IconButton(
onPressed: () => Navigator.pop(ctx),
icon: const Icon(Icons.close),
),
],
),
Text('Move to pile:', style: text.bodyMedium),
const SizedBox(height: 8),
...ValuePile.values.map((pile) {
final current = sort.assignments[card.id] == pile;
return ListTile(
contentPadding: EdgeInsets.zero,
enabled: !current,
leading: CircleAvatar(
backgroundColor: pile.accent.withValues(alpha: 0.2),
child: Text(
'${pile.rank}',
style: text.labelMedium?.copyWith(color: pile.accent),
),
),
title: Text(pile.label),
subtitle: Text(pile.subtitle),
trailing: current
? Icon(Icons.check, color: colors.primary)
: Icon(Icons.arrow_forward, color: colors.onSurfaceVariant),
onTap: current
? null
: () {
sort.moveCard(card.id, pile);
Navigator.pop(ctx);
},
);
}),
],
),
),
),
);
},
);
}
}
class _FilterChip extends StatelessWidget {
const _FilterChip({
required this.label,
required this.selected,
required this.onTap,
this.accent,
});
final String label;
final bool selected;
final VoidCallback onTap;
final Color? accent;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final labelColor = selected ? colors.onPrimary : colors.onSurface;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(label),
selected: selected,
onSelected: (_) => onTap(),
showCheckmark: true,
checkmarkColor: colors.onPrimary,
selectedColor: colors.primary,
backgroundColor: colors.surfaceContainer,
labelStyle: Theme.of(context).textTheme.labelMedium?.copyWith(
color: labelColor,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
),
side: BorderSide(
color: selected
? colors.primary
: (accent ?? colors.outlineVariant),
),
),
);
}
}
class _PileSection extends StatelessWidget {
const _PileSection({
required this.pile,
required this.cards,
required this.onDemote,
});
final ValuePile pile;
final List<ValueCard> cards;
final void Function(ValueCard card) onDemote;
@override
Widget build(BuildContext context) {
final text = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.only(bottom: 22),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.circle, size: 12, color: pile.accent),
const SizedBox(width: 8),
Expanded(
child: Text(
'${pile.label} · ${cards.length} cards',
style: text.titleLarge,
),
),
],
),
const SizedBox(height: 8),
if (pile == ValuePile.mostImportant)
...cards.map(
(card) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: ValueCardTile(
card: card,
compact: true,
trailing: TextButton(
onPressed: () => onDemote(card),
child: const Text('Reorder'),
),
),
),
)
else
Wrap(
spacing: 8,
runSpacing: 8,
children: cards
.map(
(c) => ValueChip(
title: c.title,
onTap: () => onDemote(c),
),
)
.toList(),
),
],
),
);
}
}
+41 -29
View File
@@ -1,12 +1,12 @@
/// First route shown after launch. Hands off to [MainShell] after a short delay.
/// First route shown after launch. Hands off to the practice flow.
library;
import 'package:flutter/material.dart';
import '../utils/constants.dart';
import 'main_shell.dart';
import 'practice_flow_screen.dart';
/// Branded splash with a fade-in logo. Replaced by the bottom-nav shell.
/// Soft branded splash before the welcome screen.
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@@ -32,17 +32,17 @@ class _SplashScreenState extends State<SplashScreen>
CurvedAnimation(parent: _controller, curve: Curves.easeOutBack),
);
_controller.forward();
_goToShell();
_goToPractice();
}
Future<void> _goToShell() async {
Future<void> _goToPractice() async {
await Future<void>.delayed(kSplashDuration);
if (!mounted) return;
Navigator.of(context).pushReplacement(
PageRouteBuilder<void>(
pageBuilder: (_, animation, _) => FadeTransition(
opacity: animation,
child: const MainShell(),
child: const PracticeFlowScreen(),
),
transitionDuration: const Duration(milliseconds: 400),
),
@@ -57,20 +57,27 @@ class _SplashScreenState extends State<SplashScreen>
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final colors = Theme.of(context).colorScheme;
final isDark = colors.brightness == Brightness.dark;
return Scaffold(
body: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
colors.primary,
colors.primaryContainer,
colors.tertiary,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: isDark
? [
colors.surface,
colors.surfaceContainerLow,
colors.surfaceContainer,
]
: [
colors.surface,
colors.surfaceContainerLow,
const Color(0xFFE8DFD2),
],
),
),
child: SafeArea(
@@ -82,31 +89,36 @@ class _SplashScreenState extends State<SplashScreen>
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 88,
height: 88,
decoration: BoxDecoration(
color: colors.onPrimary.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(24),
),
child: Icon(
Icons.layers_rounded,
size: 44,
color: colors.onPrimary,
ClipOval(
child: Image.asset(
'assets/images/emblem.png',
width: 88,
height: 88,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => Container(
width: 88,
height: 88,
color: colors.primaryContainer,
child: const Icon(
Icons.spa,
size: 44,
color: Colors.white,
),
),
),
),
const SizedBox(height: 24),
Text(
kAppName,
style: textTheme.displaySmall?.copyWith(
color: colors.onPrimary,
color: colors.onSurface,
),
),
const SizedBox(height: 8),
Text(
'A Material 3 starter template',
'Clarify what guides you',
style: textTheme.bodyLarge?.copyWith(
color: colors.onPrimary.withValues(alpha: 0.85),
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 40),
@@ -115,7 +127,7 @@ class _SplashScreenState extends State<SplashScreen>
height: 28,
child: CircularProgressIndicator(
strokeWidth: 3,
color: colors.onPrimary,
color: colors.primary,
),
),
],
+360
View File
@@ -0,0 +1,360 @@
/// Welcome screen with stacked sample cards and guide drawer.
library;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/values_sort_provider.dart';
import '../utils/app_theme.dart';
import '../widgets/practice_chrome.dart';
class WelcomeScreen extends StatelessWidget {
const WelcomeScreen({super.key});
@override
Widget build(BuildContext context) {
final text = Theme.of(context).textTheme;
final colors = Theme.of(context).colorScheme;
return PracticeScaffold(
header: const Padding(
padding: EdgeInsets.fromLTRB(12, 0, 12, 0),
child: BrandMark(),
),
footer: Column(
children: [
GhostPillButton(
label: 'How this practice works',
icon: Icons.info_outline,
onPressed: () => _showGuide(context),
),
PrimaryPillButton(
label: 'Begin Reflection',
icon: Icons.arrow_forward,
onPressed: () =>
context.read<ValuesSortController>().beginPractice(),
),
],
),
child: ListView(
children: [
const SizedBox(height: 8),
const _CardFan(),
const SizedBox(height: 20),
Text(
'Discover what matters most to you',
textAlign: TextAlign.center,
style: text.displaySmall,
),
const SizedBox(height: 10),
Text(
'Through a gentle, guided card-sort practice, clarify your intrinsic '
'values and illuminate the principles that guide your everyday decisions.',
textAlign: TextAlign.center,
style: text.bodyLarge?.copyWith(color: colors.onSurfaceVariant),
),
const SizedBox(height: 18),
Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
runSpacing: 6,
children: [
_Meta(icon: Icons.schedule, label: '10–15 min'),
Text('•', style: text.labelMedium),
_Meta(icon: Icons.timelapse, label: 'Self-paced'),
Text('•', style: text.labelMedium),
_Meta(icon: Icons.lock_outline, label: 'Private & reflective'),
],
),
],
),
);
}
Future<void> _showGuide(BuildContext context) async {
final controller = context.read<ValuesSortController>();
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (ctx) {
final text = Theme.of(ctx).textTheme;
final colors = Theme.of(ctx).colorScheme;
return Padding(
padding: EdgeInsets.only(
left: 24,
right: 24,
top: 8,
bottom: MediaQuery.paddingOf(ctx).bottom + 24,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.diversity_3, color: colors.tertiary),
const SizedBox(width: 8),
Expanded(
child: Text(
'The Practice of Sorting',
style: text.titleLarge,
),
),
IconButton(
onPressed: () => Navigator.pop(ctx),
icon: const Icon(Icons.close),
),
],
),
const SizedBox(height: 8),
Text(
'Grounded in contemplative psychology, this sorting practice '
'helps clarify the core tenets that give your life meaning and direction.',
style: text.bodyLarge?.copyWith(color: colors.onSurfaceVariant),
),
const SizedBox(height: 16),
const _GuideStep(
index: 1,
title: 'One card at a time',
body:
'Review cards with diverse values one at a time, checking in with your intuition without overthinking.',
),
const _GuideStep(
index: 2,
title: 'Sort into reflective piles',
body:
'Place each card into Most, Very, Important, Somewhat, or Not Important — trusting your first honest response.',
),
const _GuideStep(
index: 3,
title: 'Distill your top values',
body:
'Narrow down to your core top values, then reflect so they can guide daily decisions with clarity.',
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppTheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppTheme.outlineVariant),
),
child: Row(
children: [
Icon(Icons.spa, color: colors.tertiary, size: 18),
const SizedBox(width: 10),
Expanded(
child: Text(
'There are no right or wrong answers—only your authentic presence.',
style: text.bodyMedium?.copyWith(
fontStyle: FontStyle.italic,
),
),
),
],
),
),
const SizedBox(height: 16),
PrimaryPillButton(
label: "Got it, let's begin",
onPressed: () {
Navigator.pop(ctx);
controller.beginPractice();
},
),
],
),
);
},
);
}
}
class _Meta extends StatelessWidget {
const _Meta({required this.icon, required this.label});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final text = Theme.of(context).textTheme;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 15, color: colors.tertiary),
const SizedBox(width: 4),
Text(label, style: text.labelMedium),
],
);
}
}
class _GuideStep extends StatelessWidget {
const _GuideStep({
required this.index,
required this.title,
required this.body,
});
final int index;
final String title;
final String body;
@override
Widget build(BuildContext context) {
final text = Theme.of(context).textTheme;
final colors = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 12,
backgroundColor: AppTheme.surfaceContainerHigh,
child: Text(
'$index',
style: text.labelMedium?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: text.titleMedium),
const SizedBox(height: 2),
Text(body, style: text.bodyMedium),
],
),
),
],
),
);
}
}
class _CardFan extends StatelessWidget {
const _CardFan();
@override
Widget build(BuildContext context) {
final text = Theme.of(context).textTheme;
final colors = Theme.of(context).colorScheme;
Widget mini({
required String domain,
required String title,
required String body,
required double angle,
required double dx,
required double dy,
bool hero = false,
}) {
return Transform.translate(
offset: Offset(dx, dy),
child: Transform.rotate(
angle: angle,
child: Container(
width: hero ? 210 : 180,
height: hero ? 155 : 135,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: hero ? Colors.white : AppTheme.surfaceContainer,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppTheme.outlineVariant),
boxShadow: [
BoxShadow(
color: const Color(0x142C2825),
blurRadius: hero ? 16 : 8,
offset: const Offset(0, 4),
),
],
),
child: ClipRect(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
domain.toUpperCase(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: text.labelSmall?.copyWith(
color: hero ? colors.secondary : colors.onSurfaceVariant,
letterSpacing: 1.2,
),
),
),
if (hero)
Icon(Icons.spa, size: 16, color: colors.primary),
],
),
const SizedBox(height: 4),
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: (hero ? text.headlineMedium : text.titleLarge)
?.copyWith(fontSize: hero ? 22 : 18, height: 1.1),
),
const SizedBox(height: 4),
Expanded(
child: Text(
body,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: text.bodySmall,
),
),
],
),
),
),
),
);
}
return SizedBox(
height: 210,
child: Stack(
alignment: Alignment.center,
children: [
mini(
domain: 'Experience',
title: 'Adventure',
body: 'To have new and exciting experiences.',
angle: -0.21,
dx: -48,
dy: 10,
),
mini(
domain: 'Relation',
title: 'Compassion',
body: 'To feel and act on concern for others.',
angle: 0.21,
dx: 48,
dy: 8,
),
mini(
domain: 'Core Ground',
title: 'Genuineness',
body: 'To act in a manner that is true to who I am.',
angle: 0.02,
dx: 0,
dy: 4,
hero: true,
),
],
),
);
}
}
-97
View File
@@ -1,97 +0,0 @@
/// In-memory sample catalog used when the app is offline or in mock mode.
library;
import '../models/item.dart';
/// Static starter data so the UI is useful without a network.
abstract final class FakeItemData {
static final List<Item> items = [
Item(
id: 'local-1',
title: 'Material 3 surfaces',
subtitle: 'Use tonal layers instead of heavy drop shadows.',
description:
'Material 3 prefers surface tints and short, soft shadows. Keep cards '
'on surfaceContainerLow, bump the hero to surfaceContainerHighest, and '
'reserve elevation for sheets and dialogs.',
category: ItemCategory.design,
createdAt: DateTime(2026, 4, 12),
),
Item(
id: 'local-2',
title: 'Provider at the root',
subtitle: 'Lift shared state above the widget that consumes it.',
description:
'Wrap MaterialApp with MultiProvider so ThemeController and '
'ItemController survive route changes. Keep UI widgets dumb: they '
'read context.watch and call controller methods.',
category: ItemCategory.development,
createdAt: DateTime(2026, 5, 3),
),
Item(
id: 'local-3',
title: 'Focus on one task',
subtitle: 'A short list beats an overflowing dashboard.',
description:
'The home screen shows a greeting, a compact stat strip, and a '
'ListView.builder of items. Extra tools live one tap away on Search, '
'Profile, and Settings.',
category: ItemCategory.productivity,
createdAt: DateTime(2026, 6, 18),
),
Item(
id: 'local-4',
title: 'Breathable spacing',
subtitle: '16–24px padding and 12px gaps keep cards from crowding.',
description:
'On compact phones use 16px page padding. From 600px up, step to 24px '
'and cap the content width so lines stay readable on tablets.',
category: ItemCategory.lifestyle,
createdAt: DateTime(2026, 7, 1),
),
Item(
id: 'local-5',
title: 'Hero transitions',
subtitle: 'Match a tag on the list tile and the detail header.',
description:
'Wrap the leading glyph in a Hero with a stable tag derived from the '
'item id. The detail screen reuses the same widget so Flutter can '
'animate size and position.',
category: ItemCategory.design,
createdAt: DateTime(2026, 7, 22),
),
Item(
id: 'local-6',
title: 'Mock first, HTTP later',
subtitle: 'A repository hides whether data is fake or remote.',
description:
'ItemRepository serves FakeItemData by default and can switch to '
'ItemApi (http + JSONPlaceholder) from Settings. Failures fall back '
'to the mock list so the UI never goes empty on a demo device.',
category: ItemCategory.development,
createdAt: DateTime(2026, 8, 4),
),
Item(
id: 'local-7',
title: 'Capture ideas quickly',
subtitle: 'The add-item form validates before it writes.',
description:
'Title, summary, and description each have a length rule. Category is '
'a required dropdown. On success the new item is inserted at the top '
'of ItemController so Home and Search update immediately.',
category: ItemCategory.productivity,
createdAt: DateTime(2026, 8, 15),
),
Item(
id: 'local-8',
title: 'Light and dark equally',
subtitle: 'Seeded ColorSchemes keep contrast in both modes.',
description:
'ThemeController persists ThemeMode in shared_preferences. The '
'settings screen offers System, Light, and Dark. Both schemes come '
'from the same teal seed color.',
category: ItemCategory.lifestyle,
createdAt: DateTime(2026, 8, 20),
),
];
}
-61
View File
@@ -1,61 +0,0 @@
/// HTTP client placeholder that talks to a public sample API.
library;
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/item.dart';
/// Fetches catalog items from JSONPlaceholder.
///
/// This is intentionally a thin, replaceable client. Swap [baseUrl] and
/// [Item.fromJson] when you point the app at a real backend.
class ItemApi {
ItemApi({http.Client? client, this.baseUrl = defaultBaseUrl})
: _client = client ?? http.Client();
static const defaultBaseUrl = 'https://jsonplaceholder.typicode.com';
final http.Client _client;
final String baseUrl;
/// GET `/posts` and map each record to an [Item].
Future<List<Item>> fetchItems({int limit = 12}) async {
final uri = Uri.parse('$baseUrl/posts');
final response = await _client.get(
uri,
headers: const {'Accept': 'application/json'},
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw ItemApiException(
'Request failed (${response.statusCode})',
statusCode: response.statusCode,
);
}
final decoded = jsonDecode(response.body);
if (decoded is! List) {
throw const ItemApiException('Unexpected response shape');
}
return decoded
.whereType<Map<String, dynamic>>()
.take(limit)
.map(Item.fromJson)
.toList(growable: false);
}
void close() => _client.close();
}
class ItemApiException implements Exception {
const ItemApiException(this.message, {this.statusCode});
final String message;
final int? statusCode;
@override
String toString() => message;
}
-50
View File
@@ -1,50 +0,0 @@
/// Chooses between mock data and the HTTP client, then returns [Item] lists.
library;
import '../models/item.dart';
import 'fake_item_data.dart';
import 'item_api.dart';
/// Single entry point for catalog data. UI code should depend on this type,
/// not on [ItemApi] or [FakeItemData] directly.
class ItemRepository {
ItemRepository({
ItemApi? api,
this.useRemoteData = false,
}) : _api = api ?? ItemApi();
final ItemApi _api;
/// When true, [fetchItems] hits the network; otherwise it returns local fakes.
bool useRemoteData;
Future<List<Item>> fetchItems() async {
if (!useRemoteData) {
return List<Item>.from(FakeItemData.items);
}
try {
return await _api.fetchItems();
} catch (_) {
// Keep the demo usable if the device is offline or the API fails.
return List<Item>.from(FakeItemData.items);
}
}
/// Builds a locally-created item (no network round-trip).
Item createLocal({
required String title,
required String subtitle,
required String description,
required ItemCategory category,
}) {
return Item(
id: 'local-${DateTime.now().microsecondsSinceEpoch}',
title: title.trim(),
subtitle: subtitle.trim(),
description: description.trim(),
category: category,
createdAt: DateTime.now(),
);
}
}
+9 -4
View File
@@ -24,15 +24,20 @@ class StorageService {
}
String get themeModeName =>
_prefs.getString(StorageKeys.themeMode) ?? 'system';
_prefs.getString(StorageKeys.themeMode) ?? 'light';
Future<void> setThemeModeName(String value) {
return _prefs.setString(StorageKeys.themeMode, value);
}
bool get useRemoteData => _prefs.getBool(StorageKeys.useRemoteData) ?? false;
/// Serialized Values Sort session JSON, or null when none saved.
String? get sortSessionJson => _prefs.getString(StorageKeys.sortSession);
Future<void> setUseRemoteData(bool value) {
return _prefs.setBool(StorageKeys.useRemoteData, value);
Future<void> setSortSessionJson(String json) {
return _prefs.setString(StorageKeys.sortSession, json);
}
Future<void> clearSortSession() {
return _prefs.remove(StorageKeys.sortSession);
}
}
+302 -95
View File
@@ -1,75 +1,116 @@
/// Material 3 light and dark themes, including typography and component styles.
/// Mindful Values Architecture theme — warm surfaces with high-contrast type.
library;
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
/// Builds the [ThemeData] used by [MaterialApp] for light and dark mode.
///
/// Colors are generated from a single seed so both schemes stay consistent
/// with Material 3 tonal palettes.
/// Builds [ThemeData] tuned for readable contrast on warm paper surfaces.
abstract final class AppTheme {
static const Color seed = Color(0xFF0F766E);
/// Page canvas — light paper, clearly lighter than elevated cards.
static const Color surface = Color(0xFFFFFBF5);
static ThemeData light() => _build(Brightness.light);
/// Elevated cards / sheets — distinct from the canvas.
static const Color surfaceContainerLow = Color(0xFFFFFFFF);
static const Color surfaceContainer = Color(0xFFF3EDE4);
static const Color surfaceContainerHigh = Color(0xFFE9E2D7);
static const Color surfaceContainerHighest = Color(0xFFDED6C9);
static ThemeData dark() => _build(Brightness.dark);
/// Near-black espresso for primary copy (≈16:1 on surface).
static const Color onSurface = Color(0xFF1A1612);
static ThemeData _build(Brightness brightness) {
final colorScheme = ColorScheme.fromSeed(
seedColor: seed,
brightness: brightness,
/// Secondary copy — still strong on cream (≈9:1).
static const Color onSurfaceVariant = Color(0xFF3F3832);
/// Icons / meta — darker than the old stone grey.
static const Color outline = Color(0xFF5C534B);
static const Color outlineVariant = Color(0xFFC4B8AA);
static const Color primary = Color(0xFF4F4032);
static const Color primaryContainer = Color(0xFF6B5848);
static const Color onPrimary = Color(0xFFFFFBF5);
static const Color secondary = Color(0xFF8A3F1C);
static const Color secondaryContainer = Color(0xFFFFD4BC);
static const Color tertiary = Color(0xFF3A5238);
static const Color inverseSurface = Color(0xFF25211D);
static ThemeData light() {
const colorScheme = ColorScheme(
brightness: Brightness.light,
primary: primary,
onPrimary: onPrimary,
primaryContainer: primaryContainer,
onPrimaryContainer: Color(0xFFFFF3E8),
secondary: secondary,
onSecondary: onPrimary,
secondaryContainer: secondaryContainer,
onSecondaryContainer: Color(0xFF4A1E08),
tertiary: tertiary,
onTertiary: onPrimary,
tertiaryContainer: Color(0xFF5A7358),
onTertiaryContainer: Color(0xFFEAF6E7),
error: Color(0xFFB3261E),
onError: onPrimary,
errorContainer: Color(0xFFFFDAD6),
onErrorContainer: Color(0xFF410002),
surface: surface,
onSurface: onSurface,
surfaceContainerHighest: surfaceContainerHighest,
surfaceContainerHigh: surfaceContainerHigh,
surfaceContainer: surfaceContainer,
surfaceContainerLow: surfaceContainerLow,
surfaceContainerLowest: Color(0xFFFFFFFF),
onSurfaceVariant: onSurfaceVariant,
outline: outline,
outlineVariant: outlineVariant,
inverseSurface: inverseSurface,
onInverseSurface: Color(0xFFF7F1E8),
inversePrimary: Color(0xFFDCC4A8),
shadow: Color(0xFF1A1612),
scrim: Color(0xFF1A1612),
);
final isDark = brightness == Brightness.dark;
final textTheme = _textTheme(colorScheme);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
textTheme: textTheme,
scaffoldBackgroundColor: colorScheme.surface,
scaffoldBackgroundColor: surface,
appBarTheme: AppBarTheme(
centerTitle: false,
elevation: 0,
scrolledUnderElevation: 1,
backgroundColor: colorScheme.surface,
foregroundColor: colorScheme.onSurface,
scrolledUnderElevation: 0,
backgroundColor: surface,
foregroundColor: onSurface,
titleTextStyle: textTheme.titleLarge,
),
cardTheme: CardThemeData(
elevation: 0,
color: colorScheme.surfaceContainerLow,
color: surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
borderRadius: BorderRadius.circular(24),
side: const BorderSide(color: outlineVariant, width: 1.2),
),
clipBehavior: Clip.antiAlias,
margin: EdgeInsets.zero,
),
navigationBarTheme: NavigationBarThemeData(
elevation: 0,
height: 72,
backgroundColor: colorScheme.surfaceContainer,
indicatorColor: colorScheme.secondaryContainer,
labelTextStyle: WidgetStateProperty.resolveWith((states) {
final selected = states.contains(WidgetState.selected);
return textTheme.labelMedium?.copyWith(
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
);
}),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.6),
fillColor: Colors.white,
hintStyle: textTheme.bodyMedium?.copyWith(
color: outline.withValues(alpha: 0.85),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: outlineVariant, width: 1.2),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: outlineVariant, width: 1.2),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorScheme.primary, width: 2),
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: primary, width: 1.8),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
@@ -78,93 +119,259 @@ abstract final class AppTheme {
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: textTheme.labelLarge,
minimumSize: const Size.fromHeight(52),
backgroundColor: primary,
foregroundColor: onPrimary,
shape: const StadiumBorder(),
textStyle: textTheme.labelLarge?.copyWith(color: onPrimary),
elevation: 0,
),
),
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: onSurface,
textStyle: textTheme.labelMedium?.copyWith(
color: onSurface,
fontWeight: FontWeight.w600,
),
shape: const StadiumBorder(),
),
),
chipTheme: ChipThemeData(
backgroundColor: Colors.white,
selectedColor: surfaceContainerHigh,
side: const BorderSide(color: outlineVariant, width: 1.2),
shape: const StadiumBorder(),
labelStyle: textTheme.labelMedium?.copyWith(color: onSurface),
secondaryLabelStyle: textTheme.labelMedium?.copyWith(color: onSurface),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
),
bottomSheetTheme: const BottomSheetThemeData(
backgroundColor: surfaceContainerLow,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(32)),
),
showDragHandle: true,
),
dividerTheme: const DividerThemeData(
color: outlineVariant,
space: 1,
thickness: 1,
),
listTileTheme: const ListTileThemeData(
textColor: onSurface,
iconColor: onSurfaceVariant,
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
backgroundColor: inverseSurface,
contentTextStyle: textTheme.bodyMedium?.copyWith(
color: const Color(0xFFF7F1E8),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
dividerTheme: DividerThemeData(
color: colorScheme.outlineVariant,
space: 1,
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
listTileTheme: ListTileThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
iconColor: colorScheme.primary,
),
shadowColor: isDark ? Colors.black : const Color(0x1A0F172A),
);
}
/// Slightly tighter, more editorial type scale than the Material defaults.
/// Soft dark mirror — elevated cards stay darker than text for contrast.
static ThemeData dark() {
const darkScheme = ColorScheme(
brightness: Brightness.dark,
primary: Color(0xFFE2CDB6),
onPrimary: Color(0xFF2A2118),
primaryContainer: Color(0xFF6B5848),
onPrimaryContainer: Color(0xFFFFF3E8),
secondary: Color(0xFFFFB691),
onSecondary: Color(0xFF4A1E08),
secondaryContainer: Color(0xFF6B3014),
onSecondaryContainer: Color(0xFFFFD4BC),
tertiary: Color(0xFFB5CDB0),
onTertiary: Color(0xFF1A2A1A),
tertiaryContainer: Color(0xFF3A5238),
onTertiaryContainer: Color(0xFFEAF6E7),
error: Color(0xFFFFB4AB),
onError: Color(0xFF690005),
errorContainer: Color(0xFF93000A),
onErrorContainer: Color(0xFFFFDAD6),
surface: Color(0xFF161310),
onSurface: Color(0xFFF5EFE6),
surfaceContainerHighest: Color(0xFF3A342E),
surfaceContainerHigh: Color(0xFF302B26),
surfaceContainer: Color(0xFF26221E),
surfaceContainerLow: Color(0xFF221E1A),
surfaceContainerLowest: Color(0xFF0E0C0A),
onSurfaceVariant: Color(0xFFD9CEC2),
outline: Color(0xFFB0A397),
outlineVariant: Color(0xFF4A433C),
inverseSurface: Color(0xFFF5EFE6),
onInverseSurface: Color(0xFF1C1916),
inversePrimary: Color(0xFF4F4032),
shadow: Color(0xFF000000),
scrim: Color(0xFF000000),
);
final textTheme = _textTheme(darkScheme);
return ThemeData(
useMaterial3: true,
colorScheme: darkScheme,
textTheme: textTheme,
scaffoldBackgroundColor: darkScheme.surface,
appBarTheme: AppBarTheme(
centerTitle: false,
elevation: 0,
scrolledUnderElevation: 0,
backgroundColor: darkScheme.surface,
foregroundColor: darkScheme.onSurface,
titleTextStyle: textTheme.titleLarge,
),
cardTheme: CardThemeData(
elevation: 0,
color: darkScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
side: BorderSide(color: darkScheme.outlineVariant, width: 1.2),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: darkScheme.surfaceContainer,
hintStyle: textTheme.bodyMedium?.copyWith(
color: darkScheme.outline.withValues(alpha: 0.9),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: darkScheme.outlineVariant, width: 1.2),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: darkScheme.outlineVariant, width: 1.2),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: darkScheme.primary, width: 1.8),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
backgroundColor: darkScheme.primary,
foregroundColor: darkScheme.onPrimary,
shape: const StadiumBorder(),
textStyle: textTheme.labelLarge?.copyWith(color: darkScheme.onPrimary),
elevation: 0,
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: darkScheme.onSurface,
textStyle: textTheme.labelMedium?.copyWith(
color: darkScheme.onSurface,
fontWeight: FontWeight.w600,
),
shape: const StadiumBorder(),
),
),
chipTheme: ChipThemeData(
backgroundColor: darkScheme.surfaceContainer,
selectedColor: darkScheme.surfaceContainerHigh,
side: BorderSide(color: darkScheme.outlineVariant, width: 1.2),
shape: const StadiumBorder(),
labelStyle: textTheme.labelMedium?.copyWith(color: darkScheme.onSurface),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
),
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: darkScheme.surfaceContainerLow,
surfaceTintColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(32)),
),
showDragHandle: true,
),
listTileTheme: ListTileThemeData(
textColor: darkScheme.onSurface,
iconColor: darkScheme.onSurfaceVariant,
),
);
}
static TextTheme _textTheme(ColorScheme colors) {
const family = 'Roboto';
final newsreader = GoogleFonts.newsreaderTextTheme();
final jakarta = GoogleFonts.plusJakartaSansTextTheme();
return TextTheme(
displaySmall: TextStyle(
fontFamily: family,
fontSize: 36,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
displaySmall: newsreader.displaySmall?.copyWith(
fontSize: 32,
height: 40 / 32,
fontWeight: FontWeight.w500,
letterSpacing: -0.32,
color: colors.onSurface,
),
headlineMedium: TextStyle(
fontFamily: family,
fontSize: 28,
fontWeight: FontWeight.w600,
letterSpacing: -0.3,
headlineMedium: newsreader.headlineMedium?.copyWith(
fontSize: 26,
height: 34 / 26,
fontWeight: FontWeight.w500,
color: colors.onSurface,
),
titleLarge: TextStyle(
fontFamily: family,
headlineSmall: newsreader.headlineSmall?.copyWith(
fontSize: 22,
fontWeight: FontWeight.w600,
height: 30 / 22,
fontWeight: FontWeight.w500,
color: colors.onSurface,
),
titleMedium: TextStyle(
fontFamily: family,
titleLarge: newsreader.titleLarge?.copyWith(
fontSize: 22,
height: 30 / 22,
fontWeight: FontWeight.w500,
color: colors.onSurface,
),
titleMedium: jakarta.titleMedium?.copyWith(
fontSize: 16,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w700,
color: colors.onSurface,
),
bodyLarge: TextStyle(
fontFamily: family,
bodyLarge: jakarta.bodyLarge?.copyWith(
fontSize: 16,
height: 1.45,
height: 24 / 16,
fontWeight: FontWeight.w500,
color: colors.onSurface,
),
bodyMedium: TextStyle(
fontFamily: family,
bodyMedium: jakarta.bodyMedium?.copyWith(
fontSize: 14,
height: 1.4,
height: 20 / 14,
fontWeight: FontWeight.w500,
color: colors.onSurfaceVariant,
),
labelLarge: TextStyle(
fontFamily: family,
fontSize: 14,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
bodySmall: jakarta.bodySmall?.copyWith(
fontSize: 12,
height: 16 / 12,
fontWeight: FontWeight.w500,
color: colors.onSurfaceVariant,
),
labelLarge: jakarta.labelLarge?.copyWith(
fontSize: 15,
height: 20 / 15,
fontWeight: FontWeight.w700,
letterSpacing: 0.15,
color: colors.onPrimary,
),
labelMedium: TextStyle(
fontFamily: family,
fontSize: 12,
fontWeight: FontWeight.w500,
labelMedium: jakarta.labelMedium?.copyWith(
fontSize: 13,
height: 18 / 13,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
color: colors.onSurfaceVariant,
),
labelSmall: jakarta.labelSmall?.copyWith(
fontSize: 11,
height: 16 / 11,
fontWeight: FontWeight.w700,
letterSpacing: 0.4,
color: colors.onSurfaceVariant,
),
+7 -15
View File
@@ -1,27 +1,19 @@
/// Shared string keys, timings, and layout breakpoints used across the app.
library;
/// Human-readable name shown in the splash screen, app bars, and About tile.
const String kAppName = 'MyApp';
/// Human-readable name shown in the splash screen and brand chrome.
const String kAppName = 'Values Sort';
/// Package / Android application id.
const String kPackageName = 'com.example.myapp';
/// How long the splash screen stays visible before navigating to the shell.
const Duration kSplashDuration = Duration(seconds: 2);
/// How long the splash screen stays visible before navigating onward.
const Duration kSplashDuration = Duration(milliseconds: 1400);
/// SharedPreferences keys.
abstract final class StorageKeys {
static const themeMode = 'theme_mode';
static const useRemoteData = 'use_remote_data';
}
/// Named routes registered on [MaterialApp].
abstract final class AppRoutes {
static const splash = '/';
static const shell = '/shell';
static const itemDetail = '/item';
static const addItem = '/add-item';
static const sortSession = 'values_sort_session_v1';
}
/// Width thresholds used to adapt padding and card density.
@@ -30,5 +22,5 @@ abstract final class Breakpoints {
static const medium = 840.0;
}
/// Maximum content width on tablets / large phones so lists stay readable.
const double kMaxContentWidth = 720.0;
/// Maximum content width on tablets so reflective flows stay readable.
const double kMaxContentWidth = 600.0;
-59
View File
@@ -1,59 +0,0 @@
/// Small pure helpers for layout, formatting, and user-facing strings.
library;
import 'package:flutter/material.dart';
import 'constants.dart';
/// Horizontal padding that grows a little on wider screens.
double pagePadding(double width) {
if (width >= Breakpoints.medium) return 32;
if (width >= Breakpoints.compact) return 24;
return 16;
}
/// Formats [date] as `MMM d, y` without pulling in `intl`.
String formatDate(DateTime date) {
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
return '${months[date.month - 1]} ${date.day}, ${date.year}';
}
/// Converts a stored theme key into a [ThemeMode].
ThemeMode themeModeFromName(String name) {
return switch (name) {
'light' => ThemeMode.light,
'dark' => ThemeMode.dark,
_ => ThemeMode.system,
};
}
/// Inverse of [themeModeFromName] for persistence.
String nameFromThemeMode(ThemeMode mode) {
return switch (mode) {
ThemeMode.light => 'light',
ThemeMode.dark => 'dark',
ThemeMode.system => 'system',
};
}
/// Title-cases a [ThemeMode] for settings labels.
String labelForThemeMode(ThemeMode mode) {
return switch (mode) {
ThemeMode.light => 'Light',
ThemeMode.dark => 'Dark',
ThemeMode.system => 'System',
};
}
-44
View File
@@ -1,44 +0,0 @@
/// Labeled text field with a shared decoration used by the add-item form.
library;
import 'package:flutter/material.dart';
class AppTextField extends StatelessWidget {
const AppTextField({
super.key,
required this.controller,
required this.label,
this.hint,
this.maxLines = 1,
this.textInputAction,
this.keyboardType,
this.validator,
this.autofocus = false,
});
final TextEditingController controller;
final String label;
final String? hint;
final int maxLines;
final TextInputAction? textInputAction;
final TextInputType? keyboardType;
final String? Function(String?)? validator;
final bool autofocus;
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
maxLines: maxLines,
autofocus: autofocus,
textInputAction: textInputAction,
keyboardType: keyboardType,
validator: validator,
decoration: InputDecoration(
labelText: label,
hintText: hint,
alignLabelWithHint: maxLines > 1,
),
);
}
}
-51
View File
@@ -1,51 +0,0 @@
/// Placeholder shown when a list or search has nothing to display.
library;
import 'package:flutter/material.dart';
class EmptyState extends StatelessWidget {
const EmptyState({
super.key,
required this.icon,
required this.title,
required this.message,
this.actionLabel,
this.onAction,
});
final IconData icon;
final String title;
final String message;
final String? actionLabel;
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 48, color: colors.outline),
const SizedBox(height: 16),
Text(title, style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Text(
message,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium,
),
if (actionLabel != null && onAction != null) ...[
const SizedBox(height: 20),
FilledButton(onPressed: onAction, child: Text(actionLabel!)),
],
],
),
),
);
}
}
-127
View File
@@ -1,127 +0,0 @@
/// Tappable card used inside [ListView.builder] on Home and Search.
library;
import 'package:flutter/material.dart';
import '../models/item.dart';
import '../screens/item_detail_screen.dart';
import '../utils/helpers.dart';
import 'item_hero_badge.dart';
/// Displays one [Item] as a rounded, shadowed card that opens the detail route.
class ItemCard extends StatelessWidget {
const ItemCard({
super.key,
required this.item,
this.heroScope = 'home',
});
final Item item;
/// Prefix so Home and Search can show the same item without colliding Heroes.
final String heroScope;
String get heroTag => '$heroScope-${item.heroTag}';
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: theme.shadowColor.withValues(alpha: 0.08),
blurRadius: 18,
offset: const Offset(0, 8),
),
],
),
child: Material(
color: colors.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ItemDetailScreen(
item: item,
heroTag: heroTag,
),
),
);
},
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ItemHeroBadge(item: item, heroTag: heroTag),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.title, style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text(
item.subtitle,
style: theme.textTheme.bodyMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
_MetaChip(
label: item.category.label,
color: item.category.accent,
),
_MetaChip(
label: formatDate(item.createdAt),
color: colors.outline,
),
],
),
],
),
),
Icon(
Icons.chevron_right,
color: colors.outline,
),
],
),
),
),
),
);
}
}
class _MetaChip extends StatelessWidget {
const _MetaChip({required this.label, required this.color});
final String label;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Text(
label,
style: Theme.of(context).textTheme.labelMedium?.copyWith(color: color),
),
);
}
}
-54
View File
@@ -1,54 +0,0 @@
/// Colored glyph used as the [Hero] flight target for an [Item].
library;
import 'package:flutter/material.dart';
import '../models/item.dart';
/// Rounded square that carries the category icon. Size is animated by [Hero].
class ItemHeroBadge extends StatelessWidget {
const ItemHeroBadge({
super.key,
required this.item,
required this.heroTag,
this.size = 48,
});
final Item item;
final double size;
/// Unique among on-screen Heroes. Home and Search both list the same items,
/// so callers pass a scoped tag (for example `home-item-hero-1`).
final String heroTag;
@override
Widget build(BuildContext context) {
final radius = size * 0.28;
return Hero(
tag: heroTag,
child: Material(
color: Colors.transparent,
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: item.category.accent,
borderRadius: BorderRadius.circular(radius),
boxShadow: [
BoxShadow(
color: item.category.accent.withValues(alpha: 0.35),
blurRadius: size * 0.18,
offset: Offset(0, size * 0.08),
),
],
),
child: Icon(
item.category.icon,
color: Colors.white,
size: size * 0.5,
),
),
),
);
}
}
-24
View File
@@ -1,24 +0,0 @@
/// Centered spinner used while [ItemController] is fetching data.
library;
import 'package:flutter/material.dart';
class LoadingView extends StatelessWidget {
const LoadingView({super.key, this.message = 'Loading…'});
final String message;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(message, style: Theme.of(context).textTheme.bodyMedium),
],
),
);
}
}
+168
View File
@@ -0,0 +1,168 @@
/// Shared layout chrome for Values Sort screens.
library;
import 'package:flutter/material.dart';
import '../utils/constants.dart';
import 'theme_toggle_button.dart';
/// Centers content and applies mobile margins from the design system.
class PracticeScaffold extends StatelessWidget {
const PracticeScaffold({
super.key,
required this.child,
this.header,
this.footer,
this.padding,
this.showThemeToggle = true,
});
final Widget child;
final Widget? header;
final Widget? footer;
final EdgeInsetsGeometry? padding;
final bool showThemeToggle;
@override
Widget build(BuildContext context) {
final pad = padding ??
const EdgeInsets.fromLTRB(20, 8, 20, 16);
return Scaffold(
body: SafeArea(
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: kMaxContentWidth),
child: Column(
children: [
if (showThemeToggle)
const Padding(
padding: EdgeInsets.fromLTRB(12, 4, 12, 0),
child: Align(
alignment: Alignment.centerRight,
child: ThemeToggleButton(),
),
),
if (header != null) header!,
Expanded(
child: Padding(
padding: pad,
child: child,
),
),
if (footer != null)
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
child: footer!,
),
],
),
),
),
),
);
}
}
/// Compact brand row used on the welcome screen.
class BrandMark extends StatelessWidget {
const BrandMark({super.key});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final labels = Theme.of(context).textTheme;
return Row(
children: [
ClipOval(
child: Image.asset(
'assets/images/emblem.png',
width: 32,
height: 32,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => Container(
width: 32,
height: 32,
color: colors.primaryContainer,
child: Icon(Icons.spa, size: 18, color: colors.onPrimary),
),
),
),
const SizedBox(width: 8),
Text(
'VALUES SORT',
style: labels.labelSmall?.copyWith(
letterSpacing: 2.2,
color: colors.onSurfaceVariant,
),
),
],
);
}
}
/// Full-width primary pill CTA.
class PrimaryPillButton extends StatelessWidget {
const PrimaryPillButton({
super.key,
required this.label,
required this.onPressed,
this.icon,
});
final String label;
final VoidCallback? onPressed;
final IconData? icon;
@override
Widget build(BuildContext context) {
return FilledButton(
onPressed: onPressed,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(child: Text(label, overflow: TextOverflow.ellipsis)),
if (icon != null) ...[
const SizedBox(width: 8),
Icon(icon, size: 18),
],
],
),
);
}
}
/// Quiet secondary text action.
class GhostPillButton extends StatelessWidget {
const GhostPillButton({
super.key,
required this.label,
required this.onPressed,
this.icon,
});
final String label;
final VoidCallback? onPressed;
final IconData? icon;
@override
Widget build(BuildContext context) {
return TextButton(
onPressed: onPressed,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(label),
if (icon != null) ...[
const SizedBox(width: 6),
Icon(icon, size: 18),
],
],
),
);
}
}
-34
View File
@@ -1,34 +0,0 @@
/// Shared layout helper that centers content and caps width on large screens.
library;
import 'package:flutter/material.dart';
import '../utils/constants.dart';
import '../utils/helpers.dart';
/// Wraps [child] with responsive horizontal padding and an optional max width.
class ResponsiveBody extends StatelessWidget {
const ResponsiveBody({
super.key,
required this.child,
this.maxWidth = kMaxContentWidth,
});
final Widget child;
final double maxWidth;
@override
Widget build(BuildContext context) {
final width = MediaQuery.sizeOf(context).width;
return Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: pagePadding(width)),
child: child,
),
),
);
}
}
+35
View File
@@ -0,0 +1,35 @@
/// Moon / sun control for switching light and dark themes.
library;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/theme_provider.dart';
class ThemeToggleButton extends StatelessWidget {
const ThemeToggleButton({super.key});
@override
Widget build(BuildContext context) {
final theme = context.watch<ThemeController>();
final colors = Theme.of(context).colorScheme;
final dark = theme.isDark;
return IconButton(
tooltip: dark ? 'Switch to light mode' : 'Switch to dark mode',
onPressed: theme.toggleLightDark,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
style: IconButton.styleFrom(
backgroundColor: colors.surfaceContainer,
foregroundColor: colors.onSurfaceVariant,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
icon: Icon(
dark ? Icons.light_mode_outlined : Icons.dark_mode_outlined,
size: 16,
),
);
}
}
+100
View File
@@ -0,0 +1,100 @@
/// Visual tiles for a [ValueCard] in hero, sort, and list contexts.
library;
import 'package:flutter/material.dart';
import '../models/value_card.dart';
/// Elevated paper card used during sorting and as list detail rows.
class ValueCardTile extends StatelessWidget {
const ValueCardTile({
super.key,
required this.card,
this.compact = false,
this.trailing,
this.onTap,
this.elevation = 0,
});
final ValueCard card;
final bool compact;
final Widget? trailing;
final VoidCallback? onTap;
final double elevation;
@override
Widget build(BuildContext context) {
final text = Theme.of(context).textTheme;
final colors = Theme.of(context).colorScheme;
return Material(
color: colors.surfaceContainerLow,
elevation: elevation,
shadowColor: colors.shadow.withValues(alpha: 0.12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
side: BorderSide(color: colors.outlineVariant),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(24),
child: Padding(
padding: EdgeInsets.all(compact ? 14 : 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
card.title,
style: (compact ? text.titleLarge : text.headlineMedium)
?.copyWith(color: colors.onSurface),
),
),
if (trailing != null) ...[
const SizedBox(width: 8),
trailing!,
],
],
),
if (card.description.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
card.description,
style: text.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
maxLines: compact ? 3 : 5,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
),
);
}
}
/// Small chip used in overview grids.
class ValueChip extends StatelessWidget {
const ValueChip({
super.key,
required this.title,
this.onTap,
});
final String title;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return ActionChip(
label: Text(title),
onPressed: onTap,
visualDensity: VisualDensity.compact,
);
}
}