diff --git a/README.md b/README.md index 8055eea..d747db3 100644 --- a/README.md +++ b/README.md @@ -1,67 +1,41 @@ -# MyApp +# Values Sort -Generic Flutter Android starter built with Flutter **3.47** (Dart **3.13**), Material 3, null safety, and [Provider](https://pub.dev/packages/provider) for state management. +Flutter Android app for a guided personal-values card-sort practice. +Built with Flutter **3.47** (Dart **3.13**), Material 3, Provider, and local persistence. Package name: `com.example.myapp` -## Features +## Practice flow -- Splash screen that fades into the main shell -- Bottom navigation: Home, Search, Profile, Settings -- `ListView.builder` catalog with rounded, shadowed cards -- Detail screen with a matching `Hero` animation -- Light / dark / system theme, persisted with `shared_preferences` -- Add-item form with validation -- Mock catalog plus an `http` client aimed at JSONPlaceholder (toggle in Settings) +1. Welcome (+ how-it-works guide) +2. Sort each value into five reflective piles +3. Review / refine piles +4. Rank your most important values +5. Reflect with journal prompts + +Progress (stage, piles, ranking, reflections) is saved on device with `shared_preferences`. ## Folder structure ``` -android-flutter-app-generic/ -├── android/ # Android embedding, Gradle, manifest -├── lib/ -│ ├── main.dart # Entry point, Provider scope, MaterialApp -│ ├── models/ -│ │ └── item.dart # Item + ItemCategory -│ ├── providers/ -│ │ ├── item_provider.dart # Catalog ChangeNotifier -│ │ └── theme_provider.dart # ThemeMode ChangeNotifier -│ ├── screens/ -│ │ ├── splash_screen.dart -│ │ ├── main_shell.dart # NavigationBar host -│ │ ├── home_screen.dart -│ │ ├── search_screen.dart -│ │ ├── profile_screen.dart -│ │ ├── settings_screen.dart -│ │ ├── item_detail_screen.dart -│ │ └── add_item_screen.dart -│ ├── services/ -│ │ ├── storage_service.dart # shared_preferences wrapper -│ │ ├── item_api.dart # http placeholder -│ │ ├── fake_item_data.dart # Offline sample catalog -│ │ └── item_repository.dart # Mock vs remote switch -│ ├── utils/ -│ │ ├── app_theme.dart # Material 3 light/dark themes -│ │ ├── constants.dart -│ │ └── helpers.dart -│ └── widgets/ -│ ├── app_text_field.dart -│ ├── empty_state.dart -│ ├── item_card.dart -│ ├── item_hero_badge.dart -│ ├── loading_view.dart -│ └── responsive_body.dart -├── test/ -│ └── widget_test.dart -└── pubspec.yaml +lib/ +├── main.dart +├── data/values_deck.dart +├── models/ +├── providers/ +├── screens/ # welcome → sort → overview → rank → reflect +├── services/storage_service.dart +├── utils/ +└── widgets/ +assets/ +├── data/values_deck.json +└── images/emblem.png +stitch/ # exported Stitch HTML/PNG design references ``` -## Run the app - -You need the [Flutter SDK](https://docs.flutter.dev/get-started/install) (3.47 or newer) and an Android emulator or device. +## Run ```bash -cd android-flutter-app-generic flutter pub get flutter run ``` @@ -69,15 +43,6 @@ flutter run Useful extras: ```bash -flutter devices # list emulators / devices -flutter run -d android # target Android explicitly -flutter analyze # static analysis -flutter test # widget + unit tests +flutter analyze +flutter test ``` - -On first Android run, Flutter will download a Gradle distribution and compile the app. Accept any Android licenses with `flutter doctor --android-licenses` if prompted. - -## Theme and data - -- Open **Settings** to switch System / Light / Dark. The choice is stored locally. -- Turn on **Use remote sample API** to fetch posts from `https://jsonplaceholder.typicode.com/posts` through the `http` package. If the request fails, the app falls back to the bundled mock list. diff --git a/assets/data/values_deck.json b/assets/data/values_deck.json new file mode 100644 index 0000000..8d6d468 --- /dev/null +++ b/assets/data/values_deck.json @@ -0,0 +1,500 @@ +[ + { + "id": "v001", + "title": "Acceptance", + "description": "To be accepted as I am", + "domain": "Values" + }, + { + "id": "v002", + "title": "Accuracy", + "description": "To be accurate in my opinions and beliefs", + "domain": "Values" + }, + { + "id": "v003", + "title": "Achievement", + "description": "To have important accomplishments", + "domain": "Values" + }, + { + "id": "v004", + "title": "Adventure", + "description": "To have new and exciting experiences", + "domain": "Values" + }, + { + "id": "v005", + "title": "Attractiveness", + "description": "To be physically attractive", + "domain": "Values" + }, + { + "id": "v006", + "title": "Authority", + "description": "To be in charge of and responsible for others", + "domain": "Values" + }, + { + "id": "v007", + "title": "Autonomy", + "description": "To be self-determined and independent", + "domain": "Values" + }, + { + "id": "v008", + "title": "Beauty", + "description": "To appreciate beauty around me", + "domain": "Values" + }, + { + "id": "v009", + "title": "Caring", + "description": "To take care of others", + "domain": "Values" + }, + { + "id": "v010", + "title": "Challenge", + "description": "To take on difficult tasks and problems", + "domain": "Values" + }, + { + "id": "v011", + "title": "Comfort", + "description": "To have a pleasant and comfortable life", + "domain": "Values" + }, + { + "id": "v012", + "title": "Change", + "description": "To have a life full of change and variety", + "domain": "Values" + }, + { + "id": "v013", + "title": "Commitment", + "description": "To make enduring, meaningful commitments", + "domain": "Values" + }, + { + "id": "v014", + "title": "Compassion", + "description": "To feel and act on concern for others", + "domain": "Values" + }, + { + "id": "v015", + "title": "Contribution", + "description": "To make a lasting contribution in the world", + "domain": "Values" + }, + { + "id": "v016", + "title": "Cooperation", + "description": "To work collaboratively with others", + "domain": "Values" + }, + { + "id": "v017", + "title": "Courtesy", + "description": "To be considerate and polite toward others", + "domain": "Values" + }, + { + "id": "v018", + "title": "Creativity", + "description": "To have new and original ideas", + "domain": "Values" + }, + { + "id": "v019", + "title": "Dependability", + "description": "To be reliable and trustworthy", + "domain": "Values" + }, + { + "id": "v020", + "title": "Duty", + "description": "To carry out my duties and obligations", + "domain": "Values" + }, + { + "id": "v021", + "title": "Ecology", + "description": "To live in harmony with the environment", + "domain": "Values" + }, + { + "id": "v022", + "title": "Excitement", + "description": "To have a life full of thrills and stimulation", + "domain": "Values" + }, + { + "id": "v023", + "title": "Faithfulness", + "description": "To be loyal and true in relationships", + "domain": "Values" + }, + { + "id": "v024", + "title": "Fame", + "description": "To be known and recognized", + "domain": "Values" + }, + { + "id": "v025", + "title": "Family", + "description": "To have a happy, loving family", + "domain": "Values" + }, + { + "id": "v026", + "title": "Fitness", + "description": "To be physically fit and strong", + "domain": "Values" + }, + { + "id": "v027", + "title": "Flexibility", + "description": "To adjust to new circumstances easily", + "domain": "Values" + }, + { + "id": "v028", + "title": "Forgiveness", + "description": "To be forgiving of others", + "domain": "Values" + }, + { + "id": "v029", + "title": "Friendship", + "description": "To have close, supportive friends", + "domain": "Values" + }, + { + "id": "v030", + "title": "Fun", + "description": "To play and have fun", + "domain": "Values" + }, + { + "id": "v031", + "title": "Generosity", + "description": "To give what I have to others", + "domain": "Values" + }, + { + "id": "v032", + "title": "Genuineness", + "description": "To act in a manner that is true to who I am", + "domain": "Values" + }, + { + "id": "v033", + "title": "God's Will", + "description": "To seek and obey the will of God", + "domain": "Values" + }, + { + "id": "v034", + "title": "Growth", + "description": "To keep changing and growing", + "domain": "Values" + }, + { + "id": "v035", + "title": "Health", + "description": "To be physically well and healthy", + "domain": "Values" + }, + { + "id": "v036", + "title": "Helpfulness", + "description": "To be helpful to others", + "domain": "Values" + }, + { + "id": "v037", + "title": "Honesty", + "description": "To be honest and truthful", + "domain": "Values" + }, + { + "id": "v038", + "title": "Hope", + "description": "To maintain a positive and optimistic outlook", + "domain": "Values" + }, + { + "id": "v039", + "title": "Humility", + "description": "To be modest and unassuming", + "domain": "Values" + }, + { + "id": "v040", + "title": "Humor", + "description": "To see the humorous side of myself and the world", + "domain": "Values" + }, + { + "id": "v041", + "title": "Independence", + "description": "To be free from dependence on others", + "domain": "Values" + }, + { + "id": "v042", + "title": "Industry", + "description": "To work hard and well at my life tasks", + "domain": "Values" + }, + { + "id": "v043", + "title": "Inner Peace", + "description": "To experience personal peace", + "domain": "Values" + }, + { + "id": "v044", + "title": "Intimacy", + "description": "To share my innermost experiences with others", + "domain": "Values" + }, + { + "id": "v045", + "title": "Justice", + "description": "To promote fair and equal treatment for all", + "domain": "Values" + }, + { + "id": "v046", + "title": "Knowledge", + "description": "To learn and contribute valuable knowledge", + "domain": "Values" + }, + { + "id": "v047", + "title": "Leisure", + "description": "To take time to relax and enjoy", + "domain": "Values" + }, + { + "id": "v048", + "title": "Loved", + "description": "To be loved by those close to me", + "domain": "Values" + }, + { + "id": "v049", + "title": "Loving", + "description": "To give love to others", + "domain": "Values" + }, + { + "id": "v050", + "title": "Mastery", + "description": "To be competent in my everyday activities", + "domain": "Values" + }, + { + "id": "v051", + "title": "Mindfulness", + "description": "To live conscious and mindful of the present moment", + "domain": "Values" + }, + { + "id": "v052", + "title": "Moderation", + "description": "To avoid excesses and find a middle ground", + "domain": "Values" + }, + { + "id": "v053", + "title": "Monogamy", + "description": "To have one close, loving relationship", + "domain": "Values" + }, + { + "id": "v054", + "title": "Non-Conformity", + "description": "To question or challenge authority and norms", + "domain": "Values" + }, + { + "id": "v055", + "title": "Nurturance", + "description": "To take care of and nurture others", + "domain": "Values" + }, + { + "id": "v056", + "title": "Openness", + "description": "To be open to new experiences, ideas, and options", + "domain": "Values" + }, + { + "id": "v057", + "title": "Order", + "description": "To have a life that is well-ordered and organized", + "domain": "Values" + }, + { + "id": "v058", + "title": "Passion", + "description": "To have deep feelings about ideas, activities, or people", + "domain": "Values" + }, + { + "id": "v059", + "title": "Pleasure", + "description": "To feel good", + "domain": "Values" + }, + { + "id": "v060", + "title": "Popularity", + "description": "To be well-liked by many people", + "domain": "Values" + }, + { + "id": "v061", + "title": "Power", + "description": "To have control over others", + "domain": "Values" + }, + { + "id": "v062", + "title": "Purpose", + "description": "To have meaning and direction in my life", + "domain": "Values" + }, + { + "id": "v063", + "title": "Rationality", + "description": "To be guided by reason and logic", + "domain": "Values" + }, + { + "id": "v064", + "title": "Realism", + "description": "To see and act realistically and practically", + "domain": "Values" + }, + { + "id": "v065", + "title": "Responsibility", + "description": "To make and carry out responsible decisions", + "domain": "Values" + }, + { + "id": "v066", + "title": "Risk", + "description": "To take risks and chances", + "domain": "Values" + }, + { + "id": "v067", + "title": "Romance", + "description": "To have intense, exciting love in my life", + "domain": "Values" + }, + { + "id": "v068", + "title": "Safety", + "description": "To be safe and secure", + "domain": "Values" + }, + { + "id": "v069", + "title": "Self-Acceptance", + "description": "To accept myself as I am", + "domain": "Values" + }, + { + "id": "v070", + "title": "Self-Control", + "description": "To be disciplined in my own actions", + "domain": "Values" + }, + { + "id": "v071", + "title": "Self-Esteem", + "description": "To feel good about myself", + "domain": "Values" + }, + { + "id": "v072", + "title": "Self-Knowledge", + "description": "To have a deep and honest understanding of myself", + "domain": "Values" + }, + { + "id": "v073", + "title": "Service", + "description": "To be of service to others", + "domain": "Values" + }, + { + "id": "v074", + "title": "Sexuality", + "description": "To have an active and satisfying sex life", + "domain": "Values" + }, + { + "id": "v075", + "title": "Simplicity", + "description": "To live life simply, with minimal needs", + "domain": "Values" + }, + { + "id": "v076", + "title": "Solitude", + "description": "To have time and space where I can be apart from others", + "domain": "Values" + }, + { + "id": "v077", + "title": "Spirituality", + "description": "To grow and mature spiritually", + "domain": "Values" + }, + { + "id": "v078", + "title": "Stability", + "description": "To have a life that stays fairly consistent", + "domain": "Values" + }, + { + "id": "v079", + "title": "Tolerance", + "description": "To accept and respect those who differ from me", + "domain": "Values" + }, + { + "id": "v080", + "title": "Tradition", + "description": "To follow respected patterns of the past", + "domain": "Values" + }, + { + "id": "v081", + "title": "Virtue", + "description": "To live a morally pure and excellent life", + "domain": "Values" + }, + { + "id": "v082", + "title": "Wealth", + "description": "To have plenty of money", + "domain": "Values" + }, + { + "id": "v083", + "title": "World Peace", + "description": "To work to promote peace in the world", + "domain": "Values" + } +] diff --git a/assets/images/emblem.png b/assets/images/emblem.png new file mode 100644 index 0000000..c13f513 Binary files /dev/null and b/assets/images/emblem.png differ diff --git a/lib/data/values_deck.dart b/lib/data/values_deck.dart new file mode 100644 index 0000000..fd43321 --- /dev/null +++ b/lib/data/values_deck.dart @@ -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> loadValuesDeck() async { + final raw = await rootBundle.loadString('assets/data/values_deck.json'); + final list = jsonDecode(raw) as List; + return list + .map((e) => ValueCard.fromJson(e as Map)) + .toList(growable: false); +} diff --git a/lib/main.dart b/lib/main.dart index 850cfb3..539fcba 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 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(), diff --git a/lib/models/item.dart b/lib/models/item.dart deleted file mode 100644 index e5e06ad..0000000 --- a/lib/models/item.dart +++ /dev/null @@ -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 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 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); - } -} diff --git a/lib/models/value_card.dart b/lib/models/value_card.dart new file mode 100644 index 0000000..f4b0091 --- /dev/null +++ b/lib/models/value_card.dart @@ -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 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 toJson() => { + 'id': id, + 'title': title, + 'description': description, + 'domain': domain, + }; +} diff --git a/lib/models/value_pile.dart b/lib/models/value_pile.dart new file mode 100644 index 0000000..21abc51 --- /dev/null +++ b/lib/models/value_pile.dart @@ -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; + } +} diff --git a/lib/providers/item_provider.dart b/lib/providers/item_provider.dart deleted file mode 100644 index e44e7ea..0000000 --- a/lib/providers/item_provider.dart +++ /dev/null @@ -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 _items = const []; - bool _isLoading = false; - String? _error; - - List get items => List.unmodifiable(_items); - bool get isLoading => _isLoading; - String? get error => _error; - bool get useRemoteData => _repository.useRemoteData; - int get count => _items.length; - - Future loadItems() async { - _isLoading = true; - _error = null; - notifyListeners(); - - try { - // Small delay so the loading indicator is visible with local data. - await Future.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 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 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; - } -} diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart index 1cecdcd..8e9a31b 100644 --- a/lib/providers/theme_provider.dart +++ b/lib/providers/theme_provider.dart @@ -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 setThemeMode(ThemeMode mode) async { if (mode == _themeMode) return; _themeMode = mode; notifyListeners(); - await _storage.setThemeModeName(nameFromThemeMode(mode)); + await _storage.setThemeModeName(_toName(mode)); } - Future 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 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', + }; +} diff --git a/lib/providers/values_sort_provider.dart b/lib/providers/values_sort_provider.dart new file mode 100644 index 0000000..de71ce1 --- /dev/null +++ b/lib/providers/values_sort_provider.dart @@ -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 kReflectionPrompts = [ + 'What does this value mean to you?', + 'Why did you choose this as important?', + 'When do you live this value most fully?', + 'What gets in the way of honoring this value?', +]; + +/// Stages in the guided practice flow. +enum SortStage { + welcome, + sorting, + hub, + overview, + pileReview, + ranking, + reflection, +} + +/// ChangeNotifier that owns the deck, pile assignments, ranking, and journals. +class ValuesSortController extends ChangeNotifier { + ValuesSortController({required StorageService this._storage}); + + final StorageService _storage; + + bool _loading = true; + bool get loading => _loading; + + String? _error; + String? get error => _error; + + SortStage _stage = SortStage.welcome; + SortStage get stage => _stage; + + List _deck = const []; + List get deck => _deck; + + /// Remaining unsorted cards (front of list is current). + final List _unsorted = []; + List get unsorted => List.unmodifiable(_unsorted); + + ValueCard? get currentCard => + _unsorted.isEmpty ? null : _unsorted.first; + + final Map _assignments = {}; + Map get assignments => Map.unmodifiable(_assignments); + + /// Ordered top values (index 0 = #1 / North Star). + final List _rankedIds = []; + List get rankedIds => List.unmodifiable(_rankedIds); + + /// Reflections keyed by `valueId|promptIndex`. + final Map _reflections = {}; + + ValuePile? _reviewPile; + ValuePile? get reviewPile => _reviewPile; + + String? _activeReflectionValueId; + String? get activeReflectionValueId => _activeReflectionValueId; + + int _activePromptIndex = 0; + int get activePromptIndex => _activePromptIndex; + + final List _undoStack = []; + + bool _restoring = false; + + /// True when a non-welcome session is on disk / in memory. + bool get hasInProgressSession => + _stage != SortStage.welcome || + _assignments.isNotEmpty || + _reflections.isNotEmpty; + + Future load({List? seedDeck}) async { + _loading = true; + _error = null; + notifyListeners(); + try { + final cards = seedDeck ?? await loadValuesDeck(); + _deck = cards; + _resetSortState(); + await _restoreSession(); + } catch (e) { + _error = e.toString(); + } finally { + _loading = false; + notifyListeners(); + } + } + + String get _deckFingerprint => + _deck.map((c) => c.id).join('|'); + + void _resetSortState() { + _assignments.clear(); + _undoStack.clear(); + _unsorted + ..clear() + ..addAll(_deck); + _rankedIds.clear(); + _reflections.clear(); + _reviewPile = null; + _activeReflectionValueId = null; + _activePromptIndex = 0; + } + + void beginPractice() { + if (_deck.isEmpty) return; + _resetSortState(); + _stage = SortStage.sorting; + _commit(); + } + + void restart() { + _resetSortState(); + _stage = SortStage.welcome; + _commit(clear: true); + } + + int get sortedCount => _assignments.length; + int get totalCount => _deck.length; + double get sortProgress => + totalCount == 0 ? 0 : sortedCount / totalCount; + + bool get isSortComplete => + _unsorted.isEmpty && _assignments.length == _deck.length; + + List cardsInPile(ValuePile pile) { + return _deck + .where((c) => _assignments[c.id] == pile) + .toList(growable: false); + } + + int countInPile(ValuePile pile) => cardsInPile(pile).length; + + ValueCard? cardById(String id) { + for (final c in _deck) { + if (c.id == id) return c; + } + return null; + } + + void assignCurrent(ValuePile pile) { + final card = currentCard; + if (card == null) return; + _undoStack.add(card.id); + _assignments[card.id] = pile; + _unsorted.removeAt(0); + if (isSortComplete) { + _seedRankingFromMostImportant(); + _stage = SortStage.hub; + } + _commit(); + } + + void undoLastSort() { + if (_undoStack.isEmpty) return; + final id = _undoStack.removeLast(); + _assignments.remove(id); + final card = cardById(id); + if (card != null) { + _unsorted.insert(0, card); + } + if (_stage != SortStage.sorting) { + _stage = SortStage.sorting; + } + _commit(); + } + + void moveCard(String id, ValuePile pile) { + if (!_assignments.containsKey(id) && + !_unsorted.any((c) => c.id == id)) { + return; + } + _unsorted.removeWhere((c) => c.id == id); + _assignments[id] = pile; + _rankedIds.remove(id); + _syncRankingWithMostImportant(); + _commit(); + } + + void _seedRankingFromMostImportant() { + _rankedIds + ..clear() + ..addAll(cardsInPile(ValuePile.mostImportant).map((c) => c.id)); + } + + void _syncRankingWithMostImportant() { + final most = cardsInPile(ValuePile.mostImportant).map((c) => c.id).toSet(); + _rankedIds.removeWhere((id) => !most.contains(id)); + for (final id in most) { + if (!_rankedIds.contains(id)) { + _rankedIds.add(id); + } + } + } + + void goToHub() { + _stage = SortStage.hub; + _commit(); + } + + void goToOverview() { + _stage = SortStage.overview; + _commit(); + } + + void openPileReview(ValuePile pile) { + _reviewPile = pile; + _stage = SortStage.pileReview; + _commit(); + } + + void goToRanking() { + _syncRankingWithMostImportant(); + if (_rankedIds.isEmpty) { + final fallback = cardsInPile(ValuePile.veryImportant); + for (final c in fallback.take(8)) { + _assignments[c.id] = ValuePile.mostImportant; + _rankedIds.add(c.id); + } + } + _stage = SortStage.ranking; + _commit(); + } + + void reorderRanked(int oldIndex, int newIndex) { + final id = _rankedIds.removeAt(oldIndex); + _rankedIds.insert(newIndex, id); + _commit(); + } + + void moveRank(int index, {required bool up}) { + final target = up ? index - 1 : index + 1; + if (target < 0 || target >= _rankedIds.length) return; + final id = _rankedIds.removeAt(index); + _rankedIds.insert(target, id); + _commit(); + } + + List get rankedCards => _rankedIds + .map(cardById) + .whereType() + .toList(growable: false); + + /// Top values used for reflection (up to 5). + List get coreValues => rankedCards.take(5).toList(growable: false); + + void goToReflection({String? valueId}) { + final cores = coreValues; + if (valueId != null && cores.any((c) => c.id == valueId)) { + _activeReflectionValueId = valueId; + } else { + _activeReflectionValueId = cores.isEmpty ? null : cores.first.id; + } + _activePromptIndex = 0; + _stage = SortStage.reflection; + _commit(); + } + + void selectReflectionValue(String id) { + _activeReflectionValueId = id; + _activePromptIndex = 0; + _commit(); + } + + void selectPrompt(int index) { + _activePromptIndex = index.clamp(0, kReflectionPrompts.length - 1); + _commit(); + } + + String reflectionKey(String valueId, int promptIndex) => + '$valueId|$promptIndex'; + + String reflectionFor(String valueId, int promptIndex) => + _reflections[reflectionKey(valueId, promptIndex)] ?? ''; + + void setReflection(String valueId, int promptIndex, String text) { + final key = reflectionKey(valueId, promptIndex); + if (text.trim().isEmpty) { + _reflections.remove(key); + } else { + _reflections[key] = text.trim(); + } + _commit(); + } + + int answeredPromptCount(String valueId) { + var n = 0; + for (var i = 0; i < kReflectionPrompts.length; i++) { + if (reflectionFor(valueId, i).isNotEmpty) n++; + } + return n; + } + + int get totalAnsweredReflections { + var n = 0; + for (final c in coreValues) { + n += answeredPromptCount(c.id); + } + return n; + } + + int get totalReflectionSlots => + coreValues.length * kReflectionPrompts.length; + + void _commit({bool clear = false}) { + notifyListeners(); + if (_restoring) return; + if (clear || (_stage == SortStage.welcome && _assignments.isEmpty)) { + unawaited(_storage.clearSortSession()); + return; + } + unawaited(_persistNow()); + } + + Future _persistNow() async { + try { + final payload = { + 'version': 1, + 'deckFingerprint': _deckFingerprint, + 'stage': _stage.name, + 'reviewPile': _reviewPile?.name, + 'assignments': { + for (final e in _assignments.entries) e.key: e.value.name, + }, + 'unsortedIds': _unsorted.map((c) => c.id).toList(growable: false), + 'rankedIds': List.from(_rankedIds), + 'reflections': Map.from(_reflections), + 'activeReflectionValueId': _activeReflectionValueId, + 'activePromptIndex': _activePromptIndex, + 'undoStack': List.from(_undoStack), + }; + await _storage.setSortSessionJson(jsonEncode(payload)); + } catch (_) { + // Persistence failures should not break the practice UI. + } + } + + Future _restoreSession() async { + final raw = _storage.sortSessionJson; + if (raw == null || raw.isEmpty) return; + + try { + final data = jsonDecode(raw) as Map; + if (data['deckFingerprint'] != _deckFingerprint) { + await _storage.clearSortSession(); + return; + } + + _restoring = true; + + final knownIds = _deck.map((c) => c.id).toSet(); + final assignmentsRaw = + (data['assignments'] as Map?)?.cast() ?? {}; + _assignments.clear(); + for (final e in assignmentsRaw.entries) { + final pile = ValuePileX.fromName('${e.value}'); + if (pile != null && knownIds.contains(e.key)) { + _assignments[e.key] = pile; + } + } + + final unsortedIds = + (data['unsortedIds'] as List?)?.cast() ?? const []; + _unsorted + ..clear() + ..addAll( + unsortedIds + .map(cardById) + .whereType(), + ); + + // Recover any deck cards missing from both maps (deck updates / corruption). + final present = {..._assignments.keys, ..._unsorted.map((c) => c.id)}; + for (final card in _deck) { + if (!present.contains(card.id)) { + _unsorted.add(card); + } + } + + _rankedIds + ..clear() + ..addAll( + ((data['rankedIds'] as List?)?.cast() ?? const []) + .where(knownIds.contains), + ); + _syncRankingWithMostImportant(); + + _reflections + ..clear() + ..addAll( + ((data['reflections'] as Map?)?.cast() ?? {}) + .map((k, v) => MapEntry(k, '$v')), + ); + + _undoStack + ..clear() + ..addAll( + ((data['undoStack'] as List?)?.cast() ?? const []) + .where(knownIds.contains), + ); + + _reviewPile = ValuePileX.fromName(data['reviewPile'] as String?); + _activeReflectionValueId = data['activeReflectionValueId'] as String?; + if (_activeReflectionValueId != null && + !knownIds.contains(_activeReflectionValueId)) { + _activeReflectionValueId = null; + } + _activePromptIndex = (data['activePromptIndex'] as num?)?.toInt() ?? 0; + + final stageName = data['stage'] as String? ?? SortStage.welcome.name; + _stage = SortStage.values.firstWhere( + (s) => s.name == stageName, + orElse: () => SortStage.welcome, + ); + + // If restored mid-sort but nothing left unsorted, land on the hub. + if (_stage == SortStage.sorting && isSortComplete) { + _seedRankingFromMostImportant(); + _stage = SortStage.hub; + } + if (_stage == SortStage.pileReview && _reviewPile == null) { + _stage = SortStage.hub; + } + } catch (_) { + await _storage.clearSortSession(); + _resetSortState(); + _stage = SortStage.welcome; + } finally { + _restoring = false; + } + } + + @override + void dispose() { + if (!_restoring && hasInProgressSession) { + unawaited(_persistNow()); + } + super.dispose(); + } +} diff --git a/lib/screens/add_item_screen.dart b/lib/screens/add_item_screen.dart deleted file mode 100644 index f77f91e..0000000 --- a/lib/screens/add_item_screen.dart +++ /dev/null @@ -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 createState() => _AddItemScreenState(); -} - -class _AddItemScreenState extends State { - final _formKey = GlobalKey(); - 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().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( - 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), - ], - ), - ), - ), - ); - } -} diff --git a/lib/screens/card_sorting_screen.dart b/lib/screens/card_sorting_screen.dart new file mode 100644 index 0000000..f2300e6 --- /dev/null +++ b/lib/screens/card_sorting_screen.dart @@ -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(); + 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), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart deleted file mode 100644 index a713562..0000000 --- a/lib/screens/home_screen.dart +++ /dev/null @@ -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(); - final theme = Theme.of(context); - - return Scaffold( - appBar: AppBar(title: const Text('Home')), - floatingActionButton: FloatingActionButton.extended( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute(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, - ), - ), - ], - ), - ); - } -} diff --git a/lib/screens/item_detail_screen.dart b/lib/screens/item_detail_screen.dart deleted file mode 100644 index 794fc15..0000000 --- a/lib/screens/item_detail_screen.dart +++ /dev/null @@ -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), - ], - ), - ), - ); - } -} diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart deleted file mode 100644 index 5878dbd..0000000 --- a/lib/screens/main_shell.dart +++ /dev/null @@ -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 createState() => _MainShellState(); -} - -class _MainShellState extends State { - 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', - ), - ], - ), - ); - } -} diff --git a/lib/screens/pile_review_screen.dart b/lib/screens/pile_review_screen.dart new file mode 100644 index 0000000..e91ecfb --- /dev/null +++ b/lib/screens/pile_review_screen.dart @@ -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(); + 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]); + } +} diff --git a/lib/screens/practice_flow_screen.dart b/lib/screens/practice_flow_screen.dart new file mode 100644 index 0000000..3a70cd9 --- /dev/null +++ b/lib/screens/practice_flow_screen.dart @@ -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(); + + 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(), + }, + ), + ); + } +} diff --git a/lib/screens/practice_hub_screen.dart b/lib/screens/practice_hub_screen.dart new file mode 100644 index 0000000..ba1b679 --- /dev/null +++ b/lib/screens/practice_hub_screen.dart @@ -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(); + 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 _confirmReset( + BuildContext context, + ValuesSortController sort, + ) async { + final ok = await showModalBottomSheet( + 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 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, + ), + ), + ], + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart deleted file mode 100644 index b727639..0000000 --- a/lib/screens/profile_screen.dart +++ /dev/null @@ -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(); - final themeController = context.watch(); - 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( - 'alex@example.com', - 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( - 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), - ], - ), - ); - } -} diff --git a/lib/screens/rank_order_screen.dart b/lib/screens/rank_order_screen.dart new file mode 100644 index 0000000..25ba0d0 --- /dev/null +++ b/lib/screens/rank_order_screen.dart @@ -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(); + 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), + ), + ], + ), + ], + ), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/reflection_screen.dart b/lib/screens/reflection_screen.dart new file mode 100644 index 0000000..c4e09ac --- /dev/null +++ b/lib/screens/reflection_screen.dart @@ -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(); + 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 _openJournal( + BuildContext context, + ValuesSortController sort, + String valueId, + int promptIndex, + ) async { + sort.selectPrompt(promptIndex); + final card = sort.cardById(valueId); + if (card == null) return; + + await showModalBottomSheet( + 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 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, + ), + ], + ), + ); + } +} diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart deleted file mode 100644 index fcabda3..0000000 --- a/lib/screens/search_screen.dart +++ /dev/null @@ -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 createState() => _SearchScreenState(); -} - -class _SearchScreenState extends State { - final _query = TextEditingController(); - - @override - void dispose() { - _query.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final results = context.watch().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 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'), - ); - }, - ); - } -} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart deleted file mode 100644 index 35b0c9b..0000000 --- a/lib/screens/settings_screen.dart +++ /dev/null @@ -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(); - final items = context.watch(); - 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( - 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), - ], - ), - ), - ); - } -} diff --git a/lib/screens/sorted_overview_screen.dart b/lib/screens/sorted_overview_screen.dart new file mode 100644 index 0000000..ceabdc7 --- /dev/null +++ b/lib/screens/sorted_overview_screen.dart @@ -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 createState() => _SortedOverviewScreenState(); +} + +class _SortedOverviewScreenState extends State { + String _query = ''; + ValuePile? _filter; + + @override + Widget build(BuildContext context) { + final sort = context.watch(); + 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 _showDemoteSheet( + BuildContext context, + ValuesSortController sort, + ValueCard card, + ) async { + await showModalBottomSheet( + 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 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(), + ), + ], + ), + ); + } +} diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index 3e7df2d..e32bc98 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -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 CurvedAnimation(parent: _controller, curve: Curves.easeOutBack), ); _controller.forward(); - _goToShell(); + _goToPractice(); } - Future _goToShell() async { + Future _goToPractice() async { await Future.delayed(kSplashDuration); if (!mounted) return; Navigator.of(context).pushReplacement( PageRouteBuilder( pageBuilder: (_, animation, _) => FadeTransition( opacity: animation, - child: const MainShell(), + child: const PracticeFlowScreen(), ), transitionDuration: const Duration(milliseconds: 400), ), @@ -57,20 +57,27 @@ class _SplashScreenState extends State @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 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 height: 28, child: CircularProgressIndicator( strokeWidth: 3, - color: colors.onPrimary, + color: colors.primary, ), ), ], diff --git a/lib/screens/welcome_screen.dart b/lib/screens/welcome_screen.dart new file mode 100644 index 0000000..e380f4b --- /dev/null +++ b/lib/screens/welcome_screen.dart @@ -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().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 _showGuide(BuildContext context) async { + final controller = context.read(); + await showModalBottomSheet( + 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, + ), + ], + ), + ); + } +} diff --git a/lib/services/fake_item_data.dart b/lib/services/fake_item_data.dart deleted file mode 100644 index 9dbcdf9..0000000 --- a/lib/services/fake_item_data.dart +++ /dev/null @@ -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 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), - ), - ]; -} diff --git a/lib/services/item_api.dart b/lib/services/item_api.dart deleted file mode 100644 index 56a417c..0000000 --- a/lib/services/item_api.dart +++ /dev/null @@ -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> 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>() - .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; -} diff --git a/lib/services/item_repository.dart b/lib/services/item_repository.dart deleted file mode 100644 index 4ae8b48..0000000 --- a/lib/services/item_repository.dart +++ /dev/null @@ -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> fetchItems() async { - if (!useRemoteData) { - return List.from(FakeItemData.items); - } - - try { - return await _api.fetchItems(); - } catch (_) { - // Keep the demo usable if the device is offline or the API fails. - return List.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(), - ); - } -} diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index ea525a5..fef3955 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -24,15 +24,20 @@ class StorageService { } String get themeModeName => - _prefs.getString(StorageKeys.themeMode) ?? 'system'; + _prefs.getString(StorageKeys.themeMode) ?? 'light'; Future 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 setUseRemoteData(bool value) { - return _prefs.setBool(StorageKeys.useRemoteData, value); + Future setSortSessionJson(String json) { + return _prefs.setString(StorageKeys.sortSession, json); + } + + Future clearSortSession() { + return _prefs.remove(StorageKeys.sortSession); } } diff --git a/lib/utils/app_theme.dart b/lib/utils/app_theme.dart index 2b8d926..fb65ee5 100644 --- a/lib/utils/app_theme.dart +++ b/lib/utils/app_theme.dart @@ -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, ), diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index dcacb69..0a271ab 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -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; diff --git a/lib/utils/helpers.dart b/lib/utils/helpers.dart deleted file mode 100644 index 4d6e0fb..0000000 --- a/lib/utils/helpers.dart +++ /dev/null @@ -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', - }; -} diff --git a/lib/widgets/app_text_field.dart b/lib/widgets/app_text_field.dart deleted file mode 100644 index 4cdeb3e..0000000 --- a/lib/widgets/app_text_field.dart +++ /dev/null @@ -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, - ), - ); - } -} diff --git a/lib/widgets/empty_state.dart b/lib/widgets/empty_state.dart deleted file mode 100644 index 088c92e..0000000 --- a/lib/widgets/empty_state.dart +++ /dev/null @@ -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!)), - ], - ], - ), - ), - ); - } -} diff --git a/lib/widgets/item_card.dart b/lib/widgets/item_card.dart deleted file mode 100644 index 71ed062..0000000 --- a/lib/widgets/item_card.dart +++ /dev/null @@ -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( - 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), - ), - ); - } -} diff --git a/lib/widgets/item_hero_badge.dart b/lib/widgets/item_hero_badge.dart deleted file mode 100644 index fbbb30b..0000000 --- a/lib/widgets/item_hero_badge.dart +++ /dev/null @@ -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, - ), - ), - ), - ); - } -} diff --git a/lib/widgets/loading_view.dart b/lib/widgets/loading_view.dart deleted file mode 100644 index 1ad7652..0000000 --- a/lib/widgets/loading_view.dart +++ /dev/null @@ -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), - ], - ), - ); - } -} diff --git a/lib/widgets/practice_chrome.dart b/lib/widgets/practice_chrome.dart new file mode 100644 index 0000000..cb4ce43 --- /dev/null +++ b/lib/widgets/practice_chrome.dart @@ -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), + ], + ], + ), + ); + } +} diff --git a/lib/widgets/responsive_body.dart b/lib/widgets/responsive_body.dart deleted file mode 100644 index 089fea2..0000000 --- a/lib/widgets/responsive_body.dart +++ /dev/null @@ -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, - ), - ), - ); - } -} diff --git a/lib/widgets/theme_toggle_button.dart b/lib/widgets/theme_toggle_button.dart new file mode 100644 index 0000000..3e51448 --- /dev/null +++ b/lib/widgets/theme_toggle_button.dart @@ -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(); + 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, + ), + ); + } +} diff --git a/lib/widgets/value_card_tile.dart b/lib/widgets/value_card_tile.dart new file mode 100644 index 0000000..0b3515a --- /dev/null +++ b/lib/widgets/value_card_tile.dart @@ -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, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 5600031..4f18a1f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -33,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" collection: dependency: transitive description: @@ -41,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" cupertino_icons: dependency: "direct main" description: @@ -96,8 +120,24 @@ packages: description: flutter source: sdk version: "0.0.0" - http: + google_fonts: dependency: "direct main" + description: + name: google_fonts + sha256: e3cb3ee6b47fd2472c23de6da5744796a4da195137759ddb3fbcc9467b7b3c7d + url: "https://pub.dev" + source: hosted + version: "8.2.1" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + http: + dependency: transitive description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -112,6 +152,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" leak_tracker: dependency: transitive description: @@ -144,6 +208,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -176,6 +248,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" path: dependency: transitive description: @@ -184,6 +272,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -232,6 +344,22 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" shared_preferences: dependency: "direct main" description: @@ -381,6 +509,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" sdks: dart: ">=3.13.2 <4.0.0" flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index 20fabd9..3af2132 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: myapp -description: Generic Flutter Android starter with Material 3, Provider, and local persistence. +description: Values Sort — a guided card-sort practice for clarifying personal values. # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. publish_to: 'none' # Remove this line if you wish to publish to pub.dev @@ -34,9 +34,9 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 - provider: ^6.1.5+1 # ChangeNotifier state for theme and catalog - http: ^1.6.0 # Placeholder network client (JSONPlaceholder) - shared_preferences: ^2.5.5 # Persist theme mode and data-source toggle + provider: ^6.1.5+1 + shared_preferences: ^2.5.5 + google_fonts: ^8.2.1 dev_dependencies: flutter_test: @@ -60,10 +60,9 @@ flutter: # the material Icons class. uses-material-design: true - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg + assets: + - assets/data/values_deck.json + - assets/images/emblem.png # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/stitch/DESIGN.md b/stitch/DESIGN.md new file mode 100644 index 0000000..46fb466 --- /dev/null +++ b/stitch/DESIGN.md @@ -0,0 +1,214 @@ +--- +name: Mindful Values Architecture +colors: + surface: '#fdf9f2' + surface-dim: '#dddad3' + surface-bright: '#fdf9f2' + surface-container-lowest: '#ffffff' + surface-container-low: '#f7f3ec' + surface-container: '#f1ede6' + surface-container-high: '#ece8e1' + surface-container-highest: '#e6e2db' + on-surface: '#1c1c18' + on-surface-variant: '#4d453e' + inverse-surface: '#31302c' + inverse-on-surface: '#f4f0e9' + outline: '#7f756d' + outline-variant: '#d0c4ba' + surface-tint: '#6c5b49' + primary: '#635341' + on-primary: '#ffffff' + primary-container: '#7d6b58' + on-primary-container: '#ffefe1' + inverse-primary: '#d9c3ac' + secondary: '#8e4d2a' + on-secondary: '#ffffff' + secondary-container: '#feaa7f' + on-secondary-container: '#783d1a' + tertiary: '#465b44' + on-tertiary: '#ffffff' + tertiary-container: '#5e745c' + on-tertiary-container: '#e1f9db' + error: '#ba1a1a' + on-error: '#ffffff' + error-container: '#ffdad6' + on-error-container: '#93000a' + primary-fixed: '#f6dfc7' + primary-fixed-dim: '#d9c3ac' + on-primary-fixed: '#25190b' + on-primary-fixed-variant: '#544433' + secondary-fixed: '#ffdbcb' + secondary-fixed-dim: '#ffb691' + on-secondary-fixed: '#341100' + on-secondary-fixed-variant: '#713714' + tertiary-fixed: '#d1e9cc' + tertiary-fixed-dim: '#b5cdb0' + on-tertiary-fixed: '#0d200d' + on-tertiary-fixed-variant: '#374c36' + background: '#fdf9f2' + on-background: '#1c1c18' + surface-variant: '#e6e2db' +typography: + headline-xl: + fontFamily: Newsreader + fontSize: 40px + fontWeight: '400' + lineHeight: 48px + letterSpacing: -0.02em + headline-xl-mobile: + fontFamily: Newsreader + fontSize: 32px + fontWeight: '400' + lineHeight: 40px + letterSpacing: -0.01em + headline-lg: + fontFamily: Newsreader + fontSize: 30px + fontWeight: '400' + lineHeight: 38px + letterSpacing: -0.01em + headline-lg-mobile: + fontFamily: Newsreader + fontSize: 26px + fontWeight: '400' + lineHeight: 34px + letterSpacing: 0em + headline-md: + fontFamily: Newsreader + fontSize: 22px + fontWeight: '400' + lineHeight: 30px + body-lg: + fontFamily: Plus Jakarta Sans + fontSize: 18px + fontWeight: '400' + lineHeight: 28px + body-md: + fontFamily: Plus Jakarta Sans + fontSize: 16px + fontWeight: '400' + lineHeight: 24px + body-sm: + fontFamily: Plus Jakarta Sans + fontSize: 14px + fontWeight: '400' + lineHeight: 20px + label-lg: + fontFamily: Plus Jakarta Sans + fontSize: 15px + fontWeight: '600' + lineHeight: 20px + letterSpacing: 0.01em + label-md: + fontFamily: Plus Jakarta Sans + fontSize: 13px + fontWeight: '500' + lineHeight: 18px + letterSpacing: 0.02em + label-sm: + fontFamily: Plus Jakarta Sans + fontSize: 11px + fontWeight: '600' + lineHeight: 16px + letterSpacing: 0.04em +rounded: + sm: 0.5rem + DEFAULT: 1rem + md: 1.5rem + lg: 2rem + xl: 3rem + full: 9999px +spacing: + gutter: 1rem + gutter-mobile: 0.75rem + margin: 1.5rem + margin-mobile: 1.25rem + space-xs: 0.25rem + space-sm: 0.5rem + space-md: 1rem + space-lg: 1.5rem + space-xl: 2.5rem +--- + +## Brand & Style + +This design system is crafted for deep introspection, intentional decision-making, and personal grounding. It serves individuals navigating life transitions, therapeutic exercises, and values-clarification practices. The experience is designed to feel unhurried, quietly reverent, and deeply personal—eschewing addictive loops, loud notifications, and superficial gamification in favor of sustained focus and calm contemplation. + +The visual style blends **warm editorial minimalism** with **tactile paper-like depth**. It draws inspiration from artisanal bookbinding, linen stationery, and modern contemplative journals: +- **Atmosphere:** Serene, grounded, deliberate, and emotionally secure. +- **Rhythm:** Generous breathing room with relaxed vertical pacing, inviting the eye to linger and pause. +- **Materiality:** Soft stone surfaces, delicate warm contours, and nuanced diffuse shadows that ground elements without feeling synthetic or rigid. + +## Colors + +The palette is rooted in the natural world: mineral sands, quiet clay, sun-bleached stone, and muted foliage. The dynamic range avoids stark pure whites and abrasive digital blacks, fostering an environment free of eye strain and cognitive fatigue. + +### Color Tiers & Intent +- **Canvas Base (`#FBF9F5`):** The primary view background; evokes raw, unbleached cotton paper. +- **Surface Elevation (`#F5F1EA`):** Neutral surface for raised cards, value tiles, and interactive sheets. +- **Subtle Surface & Dividers (`#E8E2D8`):** Restful borders, inset surfaces, and non-intrusive separators. +- **Primary / Earth Ground (`#7D6B58`):** A balanced warm taupe-stone that grounds primary interactive states and contemplative highlights. +- **Secondary / Terracotta Accent (`#C87D55`):** A soft, sun-dried clay tone used selectively for active selections, progress markers, and reflective milestones. +- **Tertiary / Sage Accent (`#6B8168`):** A muted eucalyptus green designated for affirmation states, grounded confirmations, and calm categorization. +- **Text & Contrast (`#2C2825` Primary, `#4A443F` Secondary):** Deep espresso and soft slate hues that preserve crisp readability with none of the harshness of pure black. + +## Typography + +The typographic hierarchy balances literary gravitas with clean, approachable utility. + +- **Display & Headlines (`Newsreader`):** An editorial, high-character serif that confers an intellectual, gentle, book-like presence. Headlines use standard weight and slightly negative tracking to evoke printed literary works. +- **Body & Labels (`Plus Jakarta Sans`):** A soft, humanist geometric sans-serif that balances modern legibility with rounded, friendly terminal strokes. It delivers effortless readability across reflection prompts, explanatory texts, and compact mobile UI controls. +- **Rhythm & Formatting:** Line heights remain generous across all tiers to safeguard vertical breathing room and prevent visual crowding during dense contemplative sessions. + +## Layout & Spacing + +The layout is built around spacious, open single-column and modular card streams suited for portrait handheld usage. It prioritizes focus by presenting a limited number of elements on screen at any given time. + +### Grid & Form Factors +- **Mobile (<640px):** Single-column layout with fluid full-width cards anchored by `1.25rem` outer canvas padding. Component stacks maintain `space-md` gaps. +- **Tablet (640px–1024px):** Centered max-width canvas (600px for reflective sort flows, 768px for summary balance views) with `1.5rem` outer margins. +- **Sorting Viewports:** Sorting columns or buckets utilize vertical flex stacks with `space-sm` or `space-md` separation to maintain clear tactile drop zones and distinct groupings. + +## Elevation & Depth + +Visual depth is achieved through layered warm surfaces paired with whisper-soft, colored ambient shadows rather than synthetic gray blurs. + +- **Flat / Ground Level:** Page background (`#FBF9F5`) with no shadow. +- **Elevated Tiles & Cards:** Surface color `#F5F1EA` wrapped in a 1px border of `#E8E2D8`, elevated by a warm, diffuse ambient shadow: `box-shadow: 0 4px 16px -2px rgba(44, 40, 37, 0.04), 0 1px 4px 0 rgba(44, 40, 37, 0.02)`. +- **Floating Controls & Active Drags:** While dragging or floating above other content, cards subtly lift: `box-shadow: 0 12px 28px -4px rgba(44, 40, 37, 0.08), 0 2px 8px 0 rgba(44, 40, 37, 0.03)`. +- **Borders as Tone:** In place of strong contrast lines, borders act as quiet edge definitions using `#E8E2D8` to cleanly separate panels without fragmenting the canvas. + +## Shapes + +The design system embraces a high-radius, organic shape language (`roundedness: 3`). + +- **Pill Geometry:** Interactive buttons, category filters, and selection pills utilize complete capsule geometry (`rounded-full`), reinforcing physical softness and comfort in the hand. +- **Cards & Value Containers:** Larger surfaces utilize relaxed, generous border radii (`1.5rem` to `2rem` / `rounded-xl` to `rounded-2xl`), eliminating aggressive edges and providing an organic, pebble-like quality to draggable cards. +- **Inset Wells & Modals:** Droppable sort regions adopt matching rounded corners to establish a natural visual container for nested items. + +## Components + +### Buttons +- **Primary:** Full capsule (`rounded-full`) pill button with a `#2C2825` or `#7D6B58` background, `#FBF9F5` typography, and generous horizontal padding (`1.75rem`). No heavy drop shadows; tactile feedback relies on soft tonal lightening on press. +- **Secondary / Gentle:** Subtle stone pill surface (`#F5F1EA`) with a 1px `#E8E2D8` border and `#2C2825` text. +- **Ghost / Reflective:** Borderless with `#4A443F` text, emphasizing deliberate, low-urgency actions (e.g., "Skip for now", "Pause reflection"). + +### Value Cards (Sorting Unit) +- **Resting:** Background `#F5F1EA`, border 1px solid `#E8E2D8`, padding `space-lg`, radius `1.5rem`. Title set in `Newsreader` (`headline-md`) with an optional single-sentence descriptor below in `Plus Jakarta Sans` (`body-sm`, `#4A443F`). +- **Dragging / Active:** Slight scale increase (1.02x) with ambient shadow lift and an accent border transition to `#C87D55`. + +### Chips & Sort Buckets +- **Chips:** Capsule pills with `0.5rem` vertical and `1rem` horizontal padding. Selected states transition from background `#F5F1EA` to `#E8E2D8` with text shifting to `#2C2825`. +- **Sort Buckets (Drop Zones):** Spacious containers framed in dashed or soft solid lines of `#E8E2D8` with background `#FBF9F5`. Labelled with serene, muted headers (e.g., *Always True for Me*, *Important to Me*, *Rarely Considered*). + +### Lists & Prompts +- Framed in full-width soft-edged tiles or separated by subtle `#E8E2D8` hairline dividers. Touch targets remain large (`min-height: 52px`), accompanied by relaxed vertical spacing. + +### Checkboxes & Radio Buttons +- Rounded indicators (circular radios, `6px` radius checkboxes) using `#E8E2D8` borders that fill with muted sage (`#6B8168`) or terracotta (`#C87D55`) when selected, accompanied by a soft white icon check. + +### Input Fields +- Understated input panels with background `#F5F1EA`, bordered in `#E8E2D8`, rounding to `1rem`. Active focus shifts border tone smoothly to `#7D6B58` with no high-contrast glow rings. + +### Specialized Component: Contemplative Reflection Sheet +- A bottom sheet modal featuring a soft drag handle, `2rem` top border radii, linen-stone background (`#F5F1EA`), and generous whitespace designed specifically for writing personal rationales during deep values prioritization. \ No newline at end of file diff --git a/stitch/design-theme.json b/stitch/design-theme.json new file mode 100644 index 0000000..a08f816 --- /dev/null +++ b/stitch/design-theme.json @@ -0,0 +1,154 @@ +{ + "colorMode": "LIGHT", + "font": "NEWSREADER", + "roundness": "ROUND_FULL", + "customColor": "#7d6b58", + "headlineFont": "NEWSREADER", + "bodyFont": "PLUS_JAKARTA_SANS", + "labelFont": "PLUS_JAKARTA_SANS", + "namedColors": { + "surface_container_low": "#f7f3ec", + "secondary_container": "#feaa7f", + "on_error": "#ffffff", + "on_background": "#1c1c18", + "surface_bright": "#fdf9f2", + "surface_container_high": "#ece8e1", + "on_tertiary_fixed_variant": "#374c36", + "on_error_container": "#93000a", + "on_primary": "#ffffff", + "inverse_surface": "#31302c", + "on_primary_fixed_variant": "#544433", + "primary_fixed_dim": "#d9c3ac", + "on_tertiary_container": "#e1f9db", + "secondary_fixed_dim": "#ffb691", + "primary": "#635341", + "surface_container_highest": "#e6e2db", + "background": "#fdf9f2", + "surface_container_lowest": "#ffffff", + "surface_variant": "#e6e2db", + "error": "#ba1a1a", + "on_secondary_container": "#783d1a", + "on_secondary": "#ffffff", + "tertiary": "#465b44", + "tertiary_container": "#5e745c", + "on_primary_fixed": "#25190b", + "on_primary_container": "#ffefe1", + "on_secondary_fixed": "#341100", + "primary_fixed": "#f6dfc7", + "outline_variant": "#d0c4ba", + "surface_tint": "#6c5b49", + "error_container": "#ffdad6", + "surface_dim": "#dddad3", + "inverse_primary": "#d9c3ac", + "on_surface": "#1c1c18", + "secondary_fixed": "#ffdbcb", + "tertiary_fixed": "#d1e9cc", + "on_secondary_fixed_variant": "#713714", + "primary_container": "#7d6b58", + "tertiary_fixed_dim": "#b5cdb0", + "on_tertiary": "#ffffff", + "secondary": "#8e4d2a", + "on_surface_variant": "#4d453e", + "surface": "#fdf9f2", + "outline": "#7f756d", + "on_tertiary_fixed": "#0d200d", + "inverse_on_surface": "#f4f0e9", + "surface_container": "#f1ede6" + }, + "designMd": "---\nname: Mindful Values Architecture\ncolors:\n surface: '#fdf9f2'\n surface-dim: '#dddad3'\n surface-bright: '#fdf9f2'\n surface-container-lowest: '#ffffff'\n surface-container-low: '#f7f3ec'\n surface-container: '#f1ede6'\n surface-container-high: '#ece8e1'\n surface-container-highest: '#e6e2db'\n on-surface: '#1c1c18'\n on-surface-variant: '#4d453e'\n inverse-surface: '#31302c'\n inverse-on-surface: '#f4f0e9'\n outline: '#7f756d'\n outline-variant: '#d0c4ba'\n surface-tint: '#6c5b49'\n primary: '#635341'\n on-primary: '#ffffff'\n primary-container: '#7d6b58'\n on-primary-container: '#ffefe1'\n inverse-primary: '#d9c3ac'\n secondary: '#8e4d2a'\n on-secondary: '#ffffff'\n secondary-container: '#feaa7f'\n on-secondary-container: '#783d1a'\n tertiary: '#465b44'\n on-tertiary: '#ffffff'\n tertiary-container: '#5e745c'\n on-tertiary-container: '#e1f9db'\n error: '#ba1a1a'\n on-error: '#ffffff'\n error-container: '#ffdad6'\n on-error-container: '#93000a'\n primary-fixed: '#f6dfc7'\n primary-fixed-dim: '#d9c3ac'\n on-primary-fixed: '#25190b'\n on-primary-fixed-variant: '#544433'\n secondary-fixed: '#ffdbcb'\n secondary-fixed-dim: '#ffb691'\n on-secondary-fixed: '#341100'\n on-secondary-fixed-variant: '#713714'\n tertiary-fixed: '#d1e9cc'\n tertiary-fixed-dim: '#b5cdb0'\n on-tertiary-fixed: '#0d200d'\n on-tertiary-fixed-variant: '#374c36'\n background: '#fdf9f2'\n on-background: '#1c1c18'\n surface-variant: '#e6e2db'\ntypography:\n headline-xl:\n fontFamily: Newsreader\n fontSize: 40px\n fontWeight: '400'\n lineHeight: 48px\n letterSpacing: -0.02em\n headline-xl-mobile:\n fontFamily: Newsreader\n fontSize: 32px\n fontWeight: '400'\n lineHeight: 40px\n letterSpacing: -0.01em\n headline-lg:\n fontFamily: Newsreader\n fontSize: 30px\n fontWeight: '400'\n lineHeight: 38px\n letterSpacing: -0.01em\n headline-lg-mobile:\n fontFamily: Newsreader\n fontSize: 26px\n fontWeight: '400'\n lineHeight: 34px\n letterSpacing: 0em\n headline-md:\n fontFamily: Newsreader\n fontSize: 22px\n fontWeight: '400'\n lineHeight: 30px\n body-lg:\n fontFamily: Plus Jakarta Sans\n fontSize: 18px\n fontWeight: '400'\n lineHeight: 28px\n body-md:\n fontFamily: Plus Jakarta Sans\n fontSize: 16px\n fontWeight: '400'\n lineHeight: 24px\n body-sm:\n fontFamily: Plus Jakarta Sans\n fontSize: 14px\n fontWeight: '400'\n lineHeight: 20px\n label-lg:\n fontFamily: Plus Jakarta Sans\n fontSize: 15px\n fontWeight: '600'\n lineHeight: 20px\n letterSpacing: 0.01em\n label-md:\n fontFamily: Plus Jakarta Sans\n fontSize: 13px\n fontWeight: '500'\n lineHeight: 18px\n letterSpacing: 0.02em\n label-sm:\n fontFamily: Plus Jakarta Sans\n fontSize: 11px\n fontWeight: '600'\n lineHeight: 16px\n letterSpacing: 0.04em\nrounded:\n sm: 0.5rem\n DEFAULT: 1rem\n md: 1.5rem\n lg: 2rem\n xl: 3rem\n full: 9999px\nspacing:\n gutter: 1rem\n gutter-mobile: 0.75rem\n margin: 1.5rem\n margin-mobile: 1.25rem\n space-xs: 0.25rem\n space-sm: 0.5rem\n space-md: 1rem\n space-lg: 1.5rem\n space-xl: 2.5rem\n---\n\n## Brand & Style\n\nThis design system is crafted for deep introspection, intentional decision-making, and personal grounding. It serves individuals navigating life transitions, therapeutic exercises, and values-clarification practices. The experience is designed to feel unhurried, quietly reverent, and deeply personal\u2014eschewing addictive loops, loud notifications, and superficial gamification in favor of sustained focus and calm contemplation.\n\nThe visual style blends **warm editorial minimalism** with **tactile paper-like depth**. It draws inspiration from artisanal bookbinding, linen stationery, and modern contemplative journals:\n- **Atmosphere:** Serene, grounded, deliberate, and emotionally secure.\n- **Rhythm:** Generous breathing room with relaxed vertical pacing, inviting the eye to linger and pause.\n- **Materiality:** Soft stone surfaces, delicate warm contours, and nuanced diffuse shadows that ground elements without feeling synthetic or rigid.\n\n## Colors\n\nThe palette is rooted in the natural world: mineral sands, quiet clay, sun-bleached stone, and muted foliage. The dynamic range avoids stark pure whites and abrasive digital blacks, fostering an environment free of eye strain and cognitive fatigue.\n\n### Color Tiers & Intent\n- **Canvas Base (`#FBF9F5`):** The primary view background; evokes raw, unbleached cotton paper.\n- **Surface Elevation (`#F5F1EA`):** Neutral surface for raised cards, value tiles, and interactive sheets.\n- **Subtle Surface & Dividers (`#E8E2D8`):** Restful borders, inset surfaces, and non-intrusive separators.\n- **Primary / Earth Ground (`#7D6B58`):** A balanced warm taupe-stone that grounds primary interactive states and contemplative highlights.\n- **Secondary / Terracotta Accent (`#C87D55`):** A soft, sun-dried clay tone used selectively for active selections, progress markers, and reflective milestones.\n- **Tertiary / Sage Accent (`#6B8168`):** A muted eucalyptus green designated for affirmation states, grounded confirmations, and calm categorization.\n- **Text & Contrast (`#2C2825` Primary, `#4A443F` Secondary):** Deep espresso and soft slate hues that preserve crisp readability with none of the harshness of pure black.\n\n## Typography\n\nThe typographic hierarchy balances literary gravitas with clean, approachable utility. \n\n- **Display & Headlines (`Newsreader`):** An editorial, high-character serif that confers an intellectual, gentle, book-like presence. Headlines use standard weight and slightly negative tracking to evoke printed literary works.\n- **Body & Labels (`Plus Jakarta Sans`):** A soft, humanist geometric sans-serif that balances modern legibility with rounded, friendly terminal strokes. It delivers effortless readability across reflection prompts, explanatory texts, and compact mobile UI controls.\n- **Rhythm & Formatting:** Line heights remain generous across all tiers to safeguard vertical breathing room and prevent visual crowding during dense contemplative sessions.\n\n## Layout & Spacing\n\nThe layout is built around spacious, open single-column and modular card streams suited for portrait handheld usage. It prioritizes focus by presenting a limited number of elements on screen at any given time.\n\n### Grid & Form Factors\n- **Mobile (<640px):** Single-column layout with fluid full-width cards anchored by `1.25rem` outer canvas padding. Component stacks maintain `space-md` gaps.\n- **Tablet (640px\u20131024px):** Centered max-width canvas (600px for reflective sort flows, 768px for summary balance views) with `1.5rem` outer margins.\n- **Sorting Viewports:** Sorting columns or buckets utilize vertical flex stacks with `space-sm` or `space-md` separation to maintain clear tactile drop zones and distinct groupings.\n\n## Elevation & Depth\n\nVisual depth is achieved through layered warm surfaces paired with whisper-soft, colored ambient shadows rather than synthetic gray blurs.\n\n- **Flat / Ground Level:** Page background (`#FBF9F5`) with no shadow.\n- **Elevated Tiles & Cards:** Surface color `#F5F1EA` wrapped in a 1px border of `#E8E2D8`, elevated by a warm, diffuse ambient shadow: `box-shadow: 0 4px 16px -2px rgba(44, 40, 37, 0.04), 0 1px 4px 0 rgba(44, 40, 37, 0.02)`.\n- **Floating Controls & Active Drags:** While dragging or floating above other content, cards subtly lift: `box-shadow: 0 12px 28px -4px rgba(44, 40, 37, 0.08), 0 2px 8px 0 rgba(44, 40, 37, 0.03)`.\n- **Borders as Tone:** In place of strong contrast lines, borders act as quiet edge definitions using `#E8E2D8` to cleanly separate panels without fragmenting the canvas.\n\n## Shapes\n\nThe design system embraces a high-radius, organic shape language (`roundedness: 3`).\n\n- **Pill Geometry:** Interactive buttons, category filters, and selection pills utilize complete capsule geometry (`rounded-full`), reinforcing physical softness and comfort in the hand.\n- **Cards & Value Containers:** Larger surfaces utilize relaxed, generous border radii (`1.5rem` to `2rem` / `rounded-xl` to `rounded-2xl`), eliminating aggressive edges and providing an organic, pebble-like quality to draggable cards.\n- **Inset Wells & Modals:** Droppable sort regions adopt matching rounded corners to establish a natural visual container for nested items.\n\n## Components\n\n### Buttons\n- **Primary:** Full capsule (`rounded-full`) pill button with a `#2C2825` or `#7D6B58` background, `#FBF9F5` typography, and generous horizontal padding (`1.75rem`). No heavy drop shadows; tactile feedback relies on soft tonal lightening on press.\n- **Secondary / Gentle:** Subtle stone pill surface (`#F5F1EA`) with a 1px `#E8E2D8` border and `#2C2825` text.\n- **Ghost / Reflective:** Borderless with `#4A443F` text, emphasizing deliberate, low-urgency actions (e.g., \"Skip for now\", \"Pause reflection\").\n\n### Value Cards (Sorting Unit)\n- **Resting:** Background `#F5F1EA`, border 1px solid `#E8E2D8`, padding `space-lg`, radius `1.5rem`. Title set in `Newsreader` (`headline-md`) with an optional single-sentence descriptor below in `Plus Jakarta Sans` (`body-sm`, `#4A443F`).\n- **Dragging / Active:** Slight scale increase (1.02x) with ambient shadow lift and an accent border transition to `#C87D55`.\n\n### Chips & Sort Buckets\n- **Chips:** Capsule pills with `0.5rem` vertical and `1rem` horizontal padding. Selected states transition from background `#F5F1EA` to `#E8E2D8` with text shifting to `#2C2825`.\n- **Sort Buckets (Drop Zones):** Spacious containers framed in dashed or soft solid lines of `#E8E2D8` with background `#FBF9F5`. Labelled with serene, muted headers (e.g., *Always True for Me*, *Important to Me*, *Rarely Considered*).\n\n### Lists & Prompts\n- Framed in full-width soft-edged tiles or separated by subtle `#E8E2D8` hairline dividers. Touch targets remain large (`min-height: 52px`), accompanied by relaxed vertical spacing.\n\n### Checkboxes & Radio Buttons\n- Rounded indicators (circular radios, `6px` radius checkboxes) using `#E8E2D8` borders that fill with muted sage (`#6B8168`) or terracotta (`#C87D55`) when selected, accompanied by a soft white icon check.\n\n### Input Fields\n- Understated input panels with background `#F5F1EA`, bordered in `#E8E2D8`, rounding to `1rem`. Active focus shifts border tone smoothly to `#7D6B58` with no high-contrast glow rings.\n\n### Specialized Component: Contemplative Reflection Sheet\n- A bottom sheet modal featuring a soft drag handle, `2rem` top border radii, linen-stone background (`#F5F1EA`), and generous whitespace designed specifically for writing personal rationales during deep values prioritization.", + "colorVariant": "FIDELITY", + "overridePrimaryColor": "#7d6b58", + "overrideSecondaryColor": "#c87d55", + "overrideTertiaryColor": "#6b8168", + "overrideNeutralColor": "#f5f1ea", + "spacingScale": 2, + "typography": { + "label-lg": { + "fontFamily": "Plus Jakarta Sans", + "fontSize": "15px", + "fontWeight": "600", + "lineHeight": "20px", + "letterSpacing": "0.01em" + }, + "headline-lg": { + "fontFamily": "Newsreader", + "fontSize": "30px", + "fontWeight": "400", + "lineHeight": "38px", + "letterSpacing": "-0.01em" + }, + "label-md": { + "fontFamily": "Plus Jakarta Sans", + "fontSize": "13px", + "fontWeight": "500", + "lineHeight": "18px", + "letterSpacing": "0.02em" + }, + "body-md": { + "fontFamily": "Plus Jakarta Sans", + "fontSize": "16px", + "fontWeight": "400", + "lineHeight": "24px" + }, + "headline-xl": { + "fontFamily": "Newsreader", + "fontSize": "40px", + "fontWeight": "400", + "lineHeight": "48px", + "letterSpacing": "-0.02em" + }, + "headline-lg-mobile": { + "fontFamily": "Newsreader", + "fontSize": "26px", + "fontWeight": "400", + "lineHeight": "34px", + "letterSpacing": "0em" + }, + "headline-md": { + "fontFamily": "Newsreader", + "fontSize": "22px", + "fontWeight": "400", + "lineHeight": "30px" + }, + "body-lg": { + "fontFamily": "Plus Jakarta Sans", + "fontSize": "18px", + "fontWeight": "400", + "lineHeight": "28px" + }, + "body-sm": { + "fontFamily": "Plus Jakarta Sans", + "fontSize": "14px", + "fontWeight": "400", + "lineHeight": "20px" + }, + "headline-xl-mobile": { + "fontFamily": "Newsreader", + "fontSize": "32px", + "fontWeight": "400", + "lineHeight": "40px", + "letterSpacing": "-0.01em" + }, + "label-sm": { + "fontFamily": "Plus Jakarta Sans", + "fontSize": "11px", + "fontWeight": "600", + "lineHeight": "16px", + "letterSpacing": "0.04em" + } + }, + "spacing": { + "space-xs": "0.25rem", + "margin-mobile": "1.25rem", + "space-md": "1rem", + "space-lg": "1.5rem", + "margin": "1.5rem", + "gutter-mobile": "0.75rem", + "gutter": "1rem", + "space-xl": "2.5rem", + "space-sm": "0.5rem" + }, + "headlineFontFamily": "Newsreader", + "bodyFontFamily": "Plus Jakarta Sans", + "labelFontFamily": "Plus Jakarta Sans" +} \ No newline at end of file diff --git a/stitch/manifest.json b/stitch/manifest.json new file mode 100644 index 0000000..f8a1a8a --- /dev/null +++ b/stitch/manifest.json @@ -0,0 +1,110 @@ +[ + { + "id": "e3940f39a073450299cb3024914064c6", + "slug": "welcome", + "title": "Values Sort \u2014 Welcome (with Guide Drawer)", + "width": "780", + "height": "1768", + "deviceType": "MOBILE", + "htmlUrl": "https://contribution.usercontent.google.com/download?c=CgthaWRhX2NvZGVmeBJ7Eh1hcHBfY29tcGFuaW9uX2dlbmVyYXRlZF9maWxlcxpaCiVodG1sXzAwMDY1YmIyNTQwYmQ5N2IwMjNiZTk5NjE2MDEzZDgzEgsSBxD_iY311x0YAZIBIwoKcHJvamVjdF9pZBIVQhM5MzQzMzg3NTEyMzcwNzMzOTk0&filename=&opi=89354086", + "screenshotUrl": "https://lh3.googleusercontent.com/aida/AEtjO1XxoE8CbYmmotxXpSsZdC3wUE-E_xSas8nAxp_8GIUsaVeT2TjBeYVAwZM64nKp2KiGgcv4OGIq2noOJzxbWuzc-L2mitY9iU_fmiwGqgff0u7RpyjXlC0eRZlhvcRybK_E_dFQ_b8Q3GBOP06crg0MlaWtIPMS9WT-WIwmui7yJkqcEDsjlJlU8reI8HinfJtzvxK1gfOfEWbLDV5gPc7T_ypo6WR-fM3lZ35FSrkH4NQoX2e-d1-DlO0", + "htmlFile": "welcome.html", + "htmlBytes": 17353, + "screenshotFile": "welcome.png", + "screenshotBytes": 35118 + }, + { + "id": "07a9fc8b113c479db7ece5398a017a9e", + "slug": "rank-order", + "title": "Values Sort \u2014 Rank Order Top Values", + "width": "780", + "height": "4408", + "deviceType": "MOBILE", + "htmlUrl": "https://contribution.usercontent.google.com/download?c=CgthaWRhX2NvZGVmeBJ7Eh1hcHBfY29tcGFuaW9uX2dlbmVyYXRlZF9maWxlcxpaCiVodG1sXzAwMDY1YmIyY2VlNWY5MzgwMjNiZWU1MjMxMjVjMzFhEgsSBxD_iY311x0YAZIBIwoKcHJvamVjdF9pZBIVQhM5MzQzMzg3NTEyMzcwNzMzOTk0&filename=&opi=89354086", + "screenshotUrl": "https://lh3.googleusercontent.com/aida/AEtjO1WpFA576TA5wf1HnZIoIBm8m5-S0M9LlUvAz8ujVYmbkVydsAdUAmtOpQKsY9nixUIwZEd5bqrDTe6ITlJfFFRhDRxkehFVmgcdAZksW9L89pME6e2gTYuO-qqFQKwD-XAXg_PDunjOg8XZeYs5p1dIVenclfxkXwSQ4Y0IIDTHtIMONRE4S07sHY7skqnnL-f2vmbEBg3ah1WyjcLXIUFIL4AINmYK-vTMCxwwsQ_ev3qhq909m7CdoQ", + "htmlFile": "rank-order.html", + "htmlBytes": 35244, + "screenshotFile": "rank-order.png", + "screenshotBytes": 2676 + }, + { + "id": "73272c6039864978bcec444c7df2973f", + "slug": "most-important", + "title": "Values Sort \u2014 Most Important (with Demote Drawer)", + "width": "780", + "height": "6564", + "deviceType": "MOBILE", + "htmlUrl": "https://contribution.usercontent.google.com/download?c=CgthaWRhX2NvZGVmeBJ7Eh1hcHBfY29tcGFuaW9uX2dlbmVyYXRlZF9maWxlcxpaCiVodG1sXzAwMDY1YmIyNzZkYjg4ZjgwNWYxMzVmZTYyMDcyNzBkEgsSBxD_iY311x0YAZIBIwoKcHJvamVjdF9pZBIVQhM5MzQzMzg3NTEyMzcwNzMzOTk0&filename=&opi=89354086", + "screenshotUrl": "https://lh3.googleusercontent.com/aida/AEtjO1Xhu39G6wcAy9suH2BL28jVCSboZ_ydBY69b4vlY9-gtnbu66RyRiHaRDF8MkiSVfcjYFtlIVfpFgyN856OBgA4qenmijh6Uii1vIm22lmUNWElTOI1GSSUyOF_NMNLN7N3HKNwLY6pc0PlrUBoB--DVbW6-RcnqfBG-CIAFWWzuOn0YEXUIbLzFLfecIwxJbbHEblBtC8m7q0RR140TUxcbtxtzHRX_km3es8xnlabSjh1DMxb3K1Efg", + "htmlFile": "most-important.html", + "htmlBytes": 55201, + "screenshotFile": "most-important.png", + "screenshotBytes": 24782 + }, + { + "id": "8ca48b5fb47f46efb62882151cf3966d", + "slug": "card-sorting", + "title": "Values Sort \u2014 Card Sorting (5 Piles)", + "width": "780", + "height": "1768", + "deviceType": "MOBILE", + "htmlUrl": "https://contribution.usercontent.google.com/download?c=CgthaWRhX2NvZGVmeBJ7Eh1hcHBfY29tcGFuaW9uX2dlbmVyYXRlZF9maWxlcxpaCiVodG1sXzAwMDY1YmIyNTNlZWY1YjEwNGU3NTljNGQwMzgyZWUzEgsSBxD_iY311x0YAZIBIwoKcHJvamVjdF9pZBIVQhM5MzQzMzg3NTEyMzcwNzMzOTk0&filename=&opi=89354086", + "screenshotUrl": "https://lh3.googleusercontent.com/aida/AEtjO1UKPd2AvYz_Ucd2ae0AXKkL1mf0yGN6F1_AxDfBFGuduAa3Glzo-QZSGEM9bnNThdbJBwaLp6-YZKG96EL79UjeMqDwutbANo2j2Xxdxyo3S-P2kEfS5WsFqdk99nTzvphFypQtiFa5uF_CftoMwJRKKFm1D8Xo3YaGT07BEPrl09YGNSZR0wytLyEJUkl9Y2epmCJMJ_NiudNMLR-JxcsXqk5XndAVRz09mkUI3A8JitbLiv6Z3QlZDSU", + "htmlFile": "card-sorting.html", + "htmlBytes": 18377, + "screenshotFile": "card-sorting.png", + "screenshotBytes": 75318 + }, + { + "id": "12809277486904866316", + "slug": "important-cards", + "title": "Values Sort \u2014 Important Cards (Pile 3)", + "width": "780", + "height": "2766", + "deviceType": "MOBILE", + "htmlUrl": "https://contribution.usercontent.google.com/download?c=CgthaWRhX2NvZGVmeBJ7Eh1hcHBfY29tcGFuaW9uX2dlbmVyYXRlZF9maWxlcxpaCiVodG1sXzAwMDY1YmIyODM5NjZmMWUwNGU3NThiMjE2MDFkYWQxEgsSBxD_iY311x0YAZIBIwoKcHJvamVjdF9pZBIVQhM5MzQzMzg3NTEyMzcwNzMzOTk0&filename=&opi=89354086", + "screenshotUrl": "https://lh3.googleusercontent.com/aida/AEtjO1XQVoRrEK9mxF3gFjm9RD-lkFg-pwyvf_kJsLOREyZzFD3UWWWPbcivFX2aIvhiewCkv3Rw4SWGxq3AtN-TbfKPD2L4R0QKA3XPc_RImmva7z66yTUJo8bvozFBpwKdHXo5vPaxqB2-UyjVDLrfiCY75WYAkp6P0BF4W3xqoRy8mWUqe5ljCtjlIwbyo2GyR66wkPSOAeUqFl6s0jLycyB7MAaiBbBPOejz27uo2xYdbppL0Bzc7XVrpeE", + "htmlFile": "important-cards.html", + "htmlBytes": 30492, + "screenshotFile": "important-cards.png", + "screenshotBytes": 47590 + }, + { + "id": "7bb00b6022b14dd9a687b9a31ec6b1f5", + "slug": "reflection-journal", + "title": "Values Sort \u2014 Reflection (Full-Screen Journal Drawer)", + "width": "780", + "height": "3596", + "deviceType": "MOBILE", + "htmlUrl": "https://contribution.usercontent.google.com/download?c=CgthaWRhX2NvZGVmeBJ7Eh1hcHBfY29tcGFuaW9uX2dlbmVyYXRlZF9maWxlcxpaCiVodG1sXzAwMDY1YmIyYWE0ZDI3M2IwMjNiZWU1MjMxMjVjMzFhEgsSBxD_iY311x0YAZIBIwoKcHJvamVjdF9pZBIVQhM5MzQzMzg3NTEyMzcwNzMzOTk0&filename=&opi=89354086", + "screenshotUrl": "https://lh3.googleusercontent.com/aida/AEtjO1UAeYCnBIII639-PoE0tFfV3LHW2yIsQ2XGm0L6YPETkrpzkMplK_QGvg-8F8PSLt_oJCS-3Uajl851y6H1IkywclVOp_OG34ANFKKEmKIyGFxXYg-XXlWWlanu_22zhpb_lhiO4auBOIFSxwwFc2NbU3b6qlsAIlBGONtInErxesAiJILt6j1yKKM1OkGJIrh4sWabbDoAfBNaJlu_hDywInGjs4j-d6SZOEvzgunjmwSjLStHmjuQoVk", + "htmlFile": "reflection-journal.html", + "htmlBytes": 27117, + "screenshotFile": "reflection-journal.png", + "screenshotBytes": 4968 + }, + { + "id": "3064045350829608185", + "slug": "reflect-top-values", + "title": "Values Sort \u2014 Reflect on Top Values", + "width": "780", + "height": "3384", + "deviceType": "MOBILE", + "htmlUrl": "https://contribution.usercontent.google.com/download?c=CgthaWRhX2NvZGVmeBJ7Eh1hcHBfY29tcGFuaW9uX2dlbmVyYXRlZF9maWxlcxpaCiVodG1sXzAwMDY1YmIyOWJmZTJmY2UwOTY4YTAzNjNmMGQ1ODhmEgsSBxD_iY311x0YAZIBIwoKcHJvamVjdF9pZBIVQhM5MzQzMzg3NTEyMzcwNzMzOTk0&filename=&opi=89354086", + "screenshotUrl": "https://lh3.googleusercontent.com/aida/AEtjO1Uwhwbp12i_K453L9y11FLMvbRC4yDVlY0fdRpaqI4rBUKKwEFBEzO2fbvVyKER5fnk5X11ceImXKtx6h37TpNJIYr6CZNuNhJc1LNKbOGiV_CO3ee7AVc0nOez48-19Q7vf8_H6Q1DlfjZj3B3v2X23in3GCgyxo5rAa-NsMkS9_G4oCvnWSIioTblp-viQRuJhyq3jIlEldpfZPkU5FQh7hr6lH9RMVXv2Pdy_sviQJK_4R2KOQJxAUc", + "htmlFile": "reflect-top-values.html", + "htmlBytes": 20710, + "screenshotFile": "reflect-top-values.png", + "screenshotBytes": 4038 + }, + { + "id": "7247b606c08a44ef906836e5ab250fd1", + "slug": "practice-hub", + "title": "Values Sort \u2014 Practice Hub", + "width": "780", + "height": "3234", + "deviceType": "MOBILE", + "htmlFile": "practice-hub.html", + "screenshotFile": "practice-hub.png" + } +] \ No newline at end of file diff --git a/stitch/project.json b/stitch/project.json new file mode 100644 index 0000000..80c4c16 --- /dev/null +++ b/stitch/project.json @@ -0,0 +1,303 @@ +{ + "name": "projects/9343387512370733994", + "title": "Values Sort Mobile App", + "visibility": "PRIVATE", + "createTime": "2026-09-17T18:22:37.967958Z", + "updateTime": "2026-09-17T19:32:15.071439Z", + "projectType": "TEXT_TO_UI_PRO", + "thumbnailScreenshot": { + "name": "projects/9343387512370733994/screens/05c62dda94e39f4e2cb65bb50b9ec87d86c39268fc039c1f7ebc10b52944e933/fileEntries/screenshot", + "downloadUrl": "https://lh3.googleusercontent.com/aida/AEtjO1WAKWJpnfMBurZlT--UYRl0Dz9CevwxR_CIsiqZClT3GGTRynx9-KJ0fYv-JWeD-ymxbKoNpCCx-e3YybnPF-_cnlVPsht0qgChcKBG0PFxvAhgz6Xk0ceCi-zF0cCe4zd_KPQDCbNgMOg17zkUl0WO-qTqcfOjYZd1r38i9f77X-BRNyf2LOcLl3BtxXCJDbf2uv4CatP7UI0S0O8pHz54rbvBC8gOPzESLT1BRtLAjgO4aF6T5QI0Cg" + }, + "origin": "STITCH", + "deviceType": "DESKTOP", + "designTheme": { + "colorMode": "LIGHT", + "font": "NEWSREADER", + "roundness": "ROUND_FULL", + "customColor": "#7d6b58", + "headlineFont": "NEWSREADER", + "bodyFont": "PLUS_JAKARTA_SANS", + "labelFont": "PLUS_JAKARTA_SANS", + "namedColors": { + "surface_container_low": "#f7f3ec", + "secondary_container": "#feaa7f", + "on_error": "#ffffff", + "on_background": "#1c1c18", + "surface_bright": "#fdf9f2", + "surface_container_high": "#ece8e1", + "on_tertiary_fixed_variant": "#374c36", + "on_error_container": "#93000a", + "on_primary": "#ffffff", + "inverse_surface": "#31302c", + "on_primary_fixed_variant": "#544433", + "primary_fixed_dim": "#d9c3ac", + "on_tertiary_container": "#e1f9db", + "secondary_fixed_dim": "#ffb691", + "primary": "#635341", + "surface_container_highest": "#e6e2db", + "background": "#fdf9f2", + "surface_container_lowest": "#ffffff", + "surface_variant": "#e6e2db", + "error": "#ba1a1a", + "on_secondary_container": "#783d1a", + "on_secondary": "#ffffff", + "tertiary": "#465b44", + "tertiary_container": "#5e745c", + "on_primary_fixed": "#25190b", + "on_primary_container": "#ffefe1", + "on_secondary_fixed": "#341100", + "primary_fixed": "#f6dfc7", + "outline_variant": "#d0c4ba", + "surface_tint": "#6c5b49", + "error_container": "#ffdad6", + "surface_dim": "#dddad3", + "inverse_primary": "#d9c3ac", + "on_surface": "#1c1c18", + "secondary_fixed": "#ffdbcb", + "tertiary_fixed": "#d1e9cc", + "on_secondary_fixed_variant": "#713714", + "primary_container": "#7d6b58", + "tertiary_fixed_dim": "#b5cdb0", + "on_tertiary": "#ffffff", + "secondary": "#8e4d2a", + "on_surface_variant": "#4d453e", + "surface": "#fdf9f2", + "outline": "#7f756d", + "on_tertiary_fixed": "#0d200d", + "inverse_on_surface": "#f4f0e9", + "surface_container": "#f1ede6" + }, + "designMd": "---\nname: Mindful Values Architecture\ncolors:\n surface: '#fdf9f2'\n surface-dim: '#dddad3'\n surface-bright: '#fdf9f2'\n surface-container-lowest: '#ffffff'\n surface-container-low: '#f7f3ec'\n surface-container: '#f1ede6'\n surface-container-high: '#ece8e1'\n surface-container-highest: '#e6e2db'\n on-surface: '#1c1c18'\n on-surface-variant: '#4d453e'\n inverse-surface: '#31302c'\n inverse-on-surface: '#f4f0e9'\n outline: '#7f756d'\n outline-variant: '#d0c4ba'\n surface-tint: '#6c5b49'\n primary: '#635341'\n on-primary: '#ffffff'\n primary-container: '#7d6b58'\n on-primary-container: '#ffefe1'\n inverse-primary: '#d9c3ac'\n secondary: '#8e4d2a'\n on-secondary: '#ffffff'\n secondary-container: '#feaa7f'\n on-secondary-container: '#783d1a'\n tertiary: '#465b44'\n on-tertiary: '#ffffff'\n tertiary-container: '#5e745c'\n on-tertiary-container: '#e1f9db'\n error: '#ba1a1a'\n on-error: '#ffffff'\n error-container: '#ffdad6'\n on-error-container: '#93000a'\n primary-fixed: '#f6dfc7'\n primary-fixed-dim: '#d9c3ac'\n on-primary-fixed: '#25190b'\n on-primary-fixed-variant: '#544433'\n secondary-fixed: '#ffdbcb'\n secondary-fixed-dim: '#ffb691'\n on-secondary-fixed: '#341100'\n on-secondary-fixed-variant: '#713714'\n tertiary-fixed: '#d1e9cc'\n tertiary-fixed-dim: '#b5cdb0'\n on-tertiary-fixed: '#0d200d'\n on-tertiary-fixed-variant: '#374c36'\n background: '#fdf9f2'\n on-background: '#1c1c18'\n surface-variant: '#e6e2db'\ntypography:\n headline-xl:\n fontFamily: Newsreader\n fontSize: 40px\n fontWeight: '400'\n lineHeight: 48px\n letterSpacing: -0.02em\n headline-xl-mobile:\n fontFamily: Newsreader\n fontSize: 32px\n fontWeight: '400'\n lineHeight: 40px\n letterSpacing: -0.01em\n headline-lg:\n fontFamily: Newsreader\n fontSize: 30px\n fontWeight: '400'\n lineHeight: 38px\n letterSpacing: -0.01em\n headline-lg-mobile:\n fontFamily: Newsreader\n fontSize: 26px\n fontWeight: '400'\n lineHeight: 34px\n letterSpacing: 0em\n headline-md:\n fontFamily: Newsreader\n fontSize: 22px\n fontWeight: '400'\n lineHeight: 30px\n body-lg:\n fontFamily: Plus Jakarta Sans\n fontSize: 18px\n fontWeight: '400'\n lineHeight: 28px\n body-md:\n fontFamily: Plus Jakarta Sans\n fontSize: 16px\n fontWeight: '400'\n lineHeight: 24px\n body-sm:\n fontFamily: Plus Jakarta Sans\n fontSize: 14px\n fontWeight: '400'\n lineHeight: 20px\n label-lg:\n fontFamily: Plus Jakarta Sans\n fontSize: 15px\n fontWeight: '600'\n lineHeight: 20px\n letterSpacing: 0.01em\n label-md:\n fontFamily: Plus Jakarta Sans\n fontSize: 13px\n fontWeight: '500'\n lineHeight: 18px\n letterSpacing: 0.02em\n label-sm:\n fontFamily: Plus Jakarta Sans\n fontSize: 11px\n fontWeight: '600'\n lineHeight: 16px\n letterSpacing: 0.04em\nrounded:\n sm: 0.5rem\n DEFAULT: 1rem\n md: 1.5rem\n lg: 2rem\n xl: 3rem\n full: 9999px\nspacing:\n gutter: 1rem\n gutter-mobile: 0.75rem\n margin: 1.5rem\n margin-mobile: 1.25rem\n space-xs: 0.25rem\n space-sm: 0.5rem\n space-md: 1rem\n space-lg: 1.5rem\n space-xl: 2.5rem\n---\n\n## Brand & Style\n\nThis design system is crafted for deep introspection, intentional decision-making, and personal grounding. It serves individuals navigating life transitions, therapeutic exercises, and values-clarification practices. The experience is designed to feel unhurried, quietly reverent, and deeply personal\u2014eschewing addictive loops, loud notifications, and superficial gamification in favor of sustained focus and calm contemplation.\n\nThe visual style blends **warm editorial minimalism** with **tactile paper-like depth**. It draws inspiration from artisanal bookbinding, linen stationery, and modern contemplative journals:\n- **Atmosphere:** Serene, grounded, deliberate, and emotionally secure.\n- **Rhythm:** Generous breathing room with relaxed vertical pacing, inviting the eye to linger and pause.\n- **Materiality:** Soft stone surfaces, delicate warm contours, and nuanced diffuse shadows that ground elements without feeling synthetic or rigid.\n\n## Colors\n\nThe palette is rooted in the natural world: mineral sands, quiet clay, sun-bleached stone, and muted foliage. The dynamic range avoids stark pure whites and abrasive digital blacks, fostering an environment free of eye strain and cognitive fatigue.\n\n### Color Tiers & Intent\n- **Canvas Base (`#FBF9F5`):** The primary view background; evokes raw, unbleached cotton paper.\n- **Surface Elevation (`#F5F1EA`):** Neutral surface for raised cards, value tiles, and interactive sheets.\n- **Subtle Surface & Dividers (`#E8E2D8`):** Restful borders, inset surfaces, and non-intrusive separators.\n- **Primary / Earth Ground (`#7D6B58`):** A balanced warm taupe-stone that grounds primary interactive states and contemplative highlights.\n- **Secondary / Terracotta Accent (`#C87D55`):** A soft, sun-dried clay tone used selectively for active selections, progress markers, and reflective milestones.\n- **Tertiary / Sage Accent (`#6B8168`):** A muted eucalyptus green designated for affirmation states, grounded confirmations, and calm categorization.\n- **Text & Contrast (`#2C2825` Primary, `#4A443F` Secondary):** Deep espresso and soft slate hues that preserve crisp readability with none of the harshness of pure black.\n\n## Typography\n\nThe typographic hierarchy balances literary gravitas with clean, approachable utility. \n\n- **Display & Headlines (`Newsreader`):** An editorial, high-character serif that confers an intellectual, gentle, book-like presence. Headlines use standard weight and slightly negative tracking to evoke printed literary works.\n- **Body & Labels (`Plus Jakarta Sans`):** A soft, humanist geometric sans-serif that balances modern legibility with rounded, friendly terminal strokes. It delivers effortless readability across reflection prompts, explanatory texts, and compact mobile UI controls.\n- **Rhythm & Formatting:** Line heights remain generous across all tiers to safeguard vertical breathing room and prevent visual crowding during dense contemplative sessions.\n\n## Layout & Spacing\n\nThe layout is built around spacious, open single-column and modular card streams suited for portrait handheld usage. It prioritizes focus by presenting a limited number of elements on screen at any given time.\n\n### Grid & Form Factors\n- **Mobile (<640px):** Single-column layout with fluid full-width cards anchored by `1.25rem` outer canvas padding. Component stacks maintain `space-md` gaps.\n- **Tablet (640px\u20131024px):** Centered max-width canvas (600px for reflective sort flows, 768px for summary balance views) with `1.5rem` outer margins.\n- **Sorting Viewports:** Sorting columns or buckets utilize vertical flex stacks with `space-sm` or `space-md` separation to maintain clear tactile drop zones and distinct groupings.\n\n## Elevation & Depth\n\nVisual depth is achieved through layered warm surfaces paired with whisper-soft, colored ambient shadows rather than synthetic gray blurs.\n\n- **Flat / Ground Level:** Page background (`#FBF9F5`) with no shadow.\n- **Elevated Tiles & Cards:** Surface color `#F5F1EA` wrapped in a 1px border of `#E8E2D8`, elevated by a warm, diffuse ambient shadow: `box-shadow: 0 4px 16px -2px rgba(44, 40, 37, 0.04), 0 1px 4px 0 rgba(44, 40, 37, 0.02)`.\n- **Floating Controls & Active Drags:** While dragging or floating above other content, cards subtly lift: `box-shadow: 0 12px 28px -4px rgba(44, 40, 37, 0.08), 0 2px 8px 0 rgba(44, 40, 37, 0.03)`.\n- **Borders as Tone:** In place of strong contrast lines, borders act as quiet edge definitions using `#E8E2D8` to cleanly separate panels without fragmenting the canvas.\n\n## Shapes\n\nThe design system embraces a high-radius, organic shape language (`roundedness: 3`).\n\n- **Pill Geometry:** Interactive buttons, category filters, and selection pills utilize complete capsule geometry (`rounded-full`), reinforcing physical softness and comfort in the hand.\n- **Cards & Value Containers:** Larger surfaces utilize relaxed, generous border radii (`1.5rem` to `2rem` / `rounded-xl` to `rounded-2xl`), eliminating aggressive edges and providing an organic, pebble-like quality to draggable cards.\n- **Inset Wells & Modals:** Droppable sort regions adopt matching rounded corners to establish a natural visual container for nested items.\n\n## Components\n\n### Buttons\n- **Primary:** Full capsule (`rounded-full`) pill button with a `#2C2825` or `#7D6B58` background, `#FBF9F5` typography, and generous horizontal padding (`1.75rem`). No heavy drop shadows; tactile feedback relies on soft tonal lightening on press.\n- **Secondary / Gentle:** Subtle stone pill surface (`#F5F1EA`) with a 1px `#E8E2D8` border and `#2C2825` text.\n- **Ghost / Reflective:** Borderless with `#4A443F` text, emphasizing deliberate, low-urgency actions (e.g., \"Skip for now\", \"Pause reflection\").\n\n### Value Cards (Sorting Unit)\n- **Resting:** Background `#F5F1EA`, border 1px solid `#E8E2D8`, padding `space-lg`, radius `1.5rem`. Title set in `Newsreader` (`headline-md`) with an optional single-sentence descriptor below in `Plus Jakarta Sans` (`body-sm`, `#4A443F`).\n- **Dragging / Active:** Slight scale increase (1.02x) with ambient shadow lift and an accent border transition to `#C87D55`.\n\n### Chips & Sort Buckets\n- **Chips:** Capsule pills with `0.5rem` vertical and `1rem` horizontal padding. Selected states transition from background `#F5F1EA` to `#E8E2D8` with text shifting to `#2C2825`.\n- **Sort Buckets (Drop Zones):** Spacious containers framed in dashed or soft solid lines of `#E8E2D8` with background `#FBF9F5`. Labelled with serene, muted headers (e.g., *Always True for Me*, *Important to Me*, *Rarely Considered*).\n\n### Lists & Prompts\n- Framed in full-width soft-edged tiles or separated by subtle `#E8E2D8` hairline dividers. Touch targets remain large (`min-height: 52px`), accompanied by relaxed vertical spacing.\n\n### Checkboxes & Radio Buttons\n- Rounded indicators (circular radios, `6px` radius checkboxes) using `#E8E2D8` borders that fill with muted sage (`#6B8168`) or terracotta (`#C87D55`) when selected, accompanied by a soft white icon check.\n\n### Input Fields\n- Understated input panels with background `#F5F1EA`, bordered in `#E8E2D8`, rounding to `1rem`. Active focus shifts border tone smoothly to `#7D6B58` with no high-contrast glow rings.\n\n### Specialized Component: Contemplative Reflection Sheet\n- A bottom sheet modal featuring a soft drag handle, `2rem` top border radii, linen-stone background (`#F5F1EA`), and generous whitespace designed specifically for writing personal rationales during deep values prioritization.", + "colorVariant": "FIDELITY", + "overridePrimaryColor": "#7d6b58", + "overrideSecondaryColor": "#c87d55", + "overrideTertiaryColor": "#6b8168", + "overrideNeutralColor": "#f5f1ea", + "spacingScale": 2, + "typography": { + "label-lg": { + "fontFamily": "Plus Jakarta Sans", + "fontSize": "15px", + "fontWeight": "600", + "lineHeight": "20px", + "letterSpacing": "0.01em" + }, + "headline-lg": { + "fontFamily": "Newsreader", + "fontSize": "30px", + "fontWeight": "400", + "lineHeight": "38px", + "letterSpacing": "-0.01em" + }, + "label-md": { + "fontFamily": "Plus Jakarta Sans", + "fontSize": "13px", + "fontWeight": "500", + "lineHeight": "18px", + "letterSpacing": "0.02em" + }, + "body-md": { + "fontFamily": "Plus Jakarta Sans", + "fontSize": "16px", + "fontWeight": "400", + "lineHeight": "24px" + }, + "headline-xl": { + "fontFamily": "Newsreader", + "fontSize": "40px", + "fontWeight": "400", + "lineHeight": "48px", + "letterSpacing": "-0.02em" + }, + "headline-lg-mobile": { + "fontFamily": "Newsreader", + "fontSize": "26px", + "fontWeight": "400", + "lineHeight": "34px", + "letterSpacing": "0em" + }, + "headline-md": { + "fontFamily": "Newsreader", + "fontSize": "22px", + "fontWeight": "400", + "lineHeight": "30px" + }, + "body-lg": { + "fontFamily": "Plus Jakarta Sans", + "fontSize": "18px", + "fontWeight": "400", + "lineHeight": "28px" + }, + "body-sm": { + "fontFamily": "Plus Jakarta Sans", + "fontSize": "14px", + "fontWeight": "400", + "lineHeight": "20px" + }, + "headline-xl-mobile": { + "fontFamily": "Newsreader", + "fontSize": "32px", + "fontWeight": "400", + "lineHeight": "40px", + "letterSpacing": "-0.01em" + }, + "label-sm": { + "fontFamily": "Plus Jakarta Sans", + "fontSize": "11px", + "fontWeight": "600", + "lineHeight": "16px", + "letterSpacing": "0.04em" + } + }, + "spacing": { + "space-xs": "0.25rem", + "margin-mobile": "1.25rem", + "space-md": "1rem", + "space-lg": "1.5rem", + "margin": "1.5rem", + "gutter-mobile": "0.75rem", + "gutter": "1rem", + "space-xl": "2.5rem", + "space-sm": "0.5rem" + }, + "headlineFontFamily": "Newsreader", + "bodyFontFamily": "Plus Jakarta Sans", + "labelFontFamily": "Plus Jakarta Sans" + }, + "screenInstances": [ + { + "id": "07a9fc8b113c479db7ece5398a017a9e", + "sourceScreen": "projects/9343387512370733994/screens/07a9fc8b113c479db7ece5398a017a9e", + "x": 2506, + "y": 915, + "width": 390, + "height": 2124 + }, + { + "id": "12b14f518efb4476b2231b355342fb5d", + "sourceScreen": "projects/9343387512370733994/screens/12b14f518efb4476b2231b355342fb5d", + "x": 454, + "y": 2915, + "width": 390, + "height": 3434, + "hidden": true + }, + { + "id": "56fc5818737e435a8ecfe443599f2194", + "sourceScreen": "projects/9343387512370733994/screens/56fc5818737e435a8ecfe443599f2194", + "x": 1198, + "y": 2824, + "width": 390, + "height": 1383, + "hidden": true + }, + { + "id": "56fc5818737e435a8ecfe443599f2194-1789672128693", + "sourceScreen": "projects/9343387512370733994/screens/12809277486904866316", + "x": 3990, + "y": 936, + "width": 390, + "height": 1383 + }, + { + "id": "73272c6039864978bcec444c7df2973f", + "sourceScreen": "projects/9343387512370733994/screens/73272c6039864978bcec444c7df2973f", + "x": 3121, + "y": 978, + "width": 665, + "height": 2583 + }, + { + "id": "753eb21cb2754812961d396bb614563e", + "sourceScreen": "projects/9343387512370733994/screens/753eb21cb2754812961d396bb614563e", + "y": 928, + "width": 390, + "height": 1731, + "hidden": true + }, + { + "id": "7bb00b6022b14dd9a687b9a31ec6b1f5", + "sourceScreen": "projects/9343387512370733994/screens/7bb00b6022b14dd9a687b9a31ec6b1f5", + "x": 3622, + "y": 4505, + "width": 396, + "height": 1706, + "isResized": true + }, + { + "id": "8ca48b5fb47f46efb62882151cf3966d", + "sourceScreen": "projects/9343387512370733994/screens/8ca48b5fb47f46efb62882151cf3966d", + "x": 2948, + "y": 35, + "width": 395, + "height": 610 + }, + { + "id": "9b6c94164dea484f9a3bda995fac24fd", + "sourceScreen": "projects/9343387512370733994/screens/9b6c94164dea484f9a3bda995fac24fd", + "x": 1024, + "width": 512, + "height": 504 + }, + { + "id": "a646e40f534441db8c79e958d2a4443d", + "sourceScreen": "projects/9343387512370733994/screens/a646e40f534441db8c79e958d2a4443d", + "x": 2546, + "y": 4035, + "width": 390, + "height": 1836, + "hidden": true + }, + { + "id": "a646e40f534441db8c79e958d2a4443d-1789672538001", + "sourceScreen": "projects/9343387512370733994/screens/3064045350829608185", + "x": 3098, + "y": 4088, + "width": 460, + "height": 1704, + "isResized": true + }, + { + "id": "ae3467a3fe474e3eab1a2347b58853ef", + "sourceScreen": "projects/9343387512370733994/screens/ae3467a3fe474e3eab1a2347b58853ef", + "x": 1600, + "width": 390, + "height": 712, + "hidden": true + }, + { + "id": "ae3467a3fe474e3eab1a2347b58853ef-1789669940549", + "sourceScreen": "projects/9343387512370733994/screens/12479456381332509331", + "x": 2052, + "width": 390, + "height": 672, + "hidden": true + }, + { + "id": "assets_e82a9b9dabc94f3f883590193cadd6db", + "width": 960, + "height": 540, + "sourceAsset": "assets/e82a9b9dabc94f3f883590193cadd6db", + "type": "DESIGN_SYSTEM_INSTANCE" + }, + { + "id": "e3940f39a073450299cb3024914064c6", + "sourceScreen": "projects/9343387512370733994/screens/e3940f39a073450299cb3024914064c6", + "x": 2506, + "width": 390, + "height": 672 + }, + { + "id": "e3940f39a073450299cb3024914064c6-1789670090319", + "sourceScreen": "projects/9343387512370733994/screens/91919302947259240", + "x": 2068, + "width": 390, + "height": 672, + "hidden": true + } + ], + "metadata": { + "userRole": "OWNER" + } +} \ No newline at end of file diff --git a/stitch/screens/card-sorting.html b/stitch/screens/card-sorting.html new file mode 100644 index 0000000..4870ca2 --- /dev/null +++ b/stitch/screens/card-sorting.html @@ -0,0 +1,391 @@ +
+
+ +
+ +Stage 1 · Initial Sort +
+ +
+ +
+
+104 of 100 values sorted +100% +
+
+
+
+
+
+ +
+
+ +
+
+
+ +
+ + +
+ +

Compassion

+ +

Meeting human vulnerability, struggle, and limitation with open warmth and kindness.

+ + +
+
+
+ +
+ +
+ +layers + Assign to reflective pile + +Tap to sort +
+ +
+ + + + + + + + + + +
+ + +
+
+ + + \ No newline at end of file diff --git a/stitch/screens/card-sorting.json b/stitch/screens/card-sorting.json new file mode 100644 index 0000000..4461f24 --- /dev/null +++ b/stitch/screens/card-sorting.json @@ -0,0 +1,16 @@ +{ + "name": "projects/9343387512370733994/screens/8ca48b5fb47f46efb62882151cf3966d", + "title": "Values Sort \u2014 Card Sorting (5 Piles)", + "screenshot": { + "name": "projects/9343387512370733994/files/15029646364846437108", + "downloadUrl": "https://lh3.googleusercontent.com/aida/AEtjO1UKPd2AvYz_Ucd2ae0AXKkL1mf0yGN6F1_AxDfBFGuduAa3Glzo-QZSGEM9bnNThdbJBwaLp6-YZKG96EL79UjeMqDwutbANo2j2Xxdxyo3S-P2kEfS5WsFqdk99nTzvphFypQtiFa5uF_CftoMwJRKKFm1D8Xo3YaGT07BEPrl09YGNSZR0wytLyEJUkl9Y2epmCJMJ_NiudNMLR-JxcsXqk5XndAVRz09mkUI3A8JitbLiv6Z3QlZDSU" + }, + "htmlCode": { + "name": "projects/9343387512370733994/files/10948046668312136526", + "downloadUrl": "https://contribution.usercontent.google.com/download?c=CgthaWRhX2NvZGVmeBJ7Eh1hcHBfY29tcGFuaW9uX2dlbmVyYXRlZF9maWxlcxpaCiVodG1sXzAwMDY1YmIyNTNlZWY1YjEwNGU3NTljNGQwMzgyZWUzEgsSBxD_iY311x0YAZIBIwoKcHJvamVjdF9pZBIVQhM5MzQzMzg3NTEyMzcwNzMzOTk0&filename=&opi=89354086", + "mimeType": "text/html" + }, + "width": "780", + "height": "1768", + "deviceType": "MOBILE" +} \ No newline at end of file diff --git a/stitch/screens/card-sorting.png b/stitch/screens/card-sorting.png new file mode 100644 index 0000000..bb5682b Binary files /dev/null and b/stitch/screens/card-sorting.png differ diff --git a/stitch/screens/important-cards.html b/stitch/screens/important-cards.html new file mode 100644 index 0000000..869ac0a --- /dev/null +++ b/stitch/screens/important-cards.html @@ -0,0 +1,467 @@ + + + + +
+ +
+
+
+Step 3 of 5 + +Reviewing Group +
+ +
+

+ Values Sort — Important Cards (Pile 3) +

+

+ 32 values considered meaningful, though secondary to your core anchors. +

+
+ + + +
+
+search + +
+ +
+ + + + + + +
+
+ +
+ +
+
+
+ + Presence + +

+ Equanimity +

+
+
+ + +
+
+

+ Maintaining inner balance and composure regardless of external turmoil or unexpected storms. +

+
+ +
+
+
+ + Growth + +

+ Curiosity +

+
+
+ + +
+
+

+ Approaching unknown frontiers with open wonder and eager inquiry rather than hasty assumptions. +

+
+ +
+
+
+ + Connection + +

+ Sincerity +

+
+
+ + +
+
+

+ Living and communicating without pretense, honoring what is genuine between ourselves and others. +

+
+ +
+
+
+ + Agency + +

+ Autonomy +

+
+
+ + +
+
+

+ The freedom to direct one’s own destiny, making choices grounded in authentic personal conviction. +

+
+ +
+
+
+ + Community + +

+ Hospitality +

+
+
+ + +
+
+

+ Extending warm physical and emotional sanctuary to neighbors, wanderers, and dear companions. +

+
+ +
+
+
+ + Growth + +

+ Craftsmanship +

+
+
+ + +
+
+

+ Devoting unhurried patience to shaping work with precision, care, and silent dedication. +

+
+
+ + + +
+
+ + +
+
+ +
+ + +
+check_circle +Value moved +
+
+
\ No newline at end of file diff --git a/stitch/screens/important-cards.json b/stitch/screens/important-cards.json new file mode 100644 index 0000000..5c5966e --- /dev/null +++ b/stitch/screens/important-cards.json @@ -0,0 +1,16 @@ +{ + "name": "projects/9343387512370733994/screens/12809277486904866316", + "title": "Values Sort \u2014 Important Cards (Pile 3)", + "screenshot": { + "name": "projects/9343387512370733994/files/12809277486904866801", + "downloadUrl": "https://lh3.googleusercontent.com/aida/AEtjO1XQVoRrEK9mxF3gFjm9RD-lkFg-pwyvf_kJsLOREyZzFD3UWWWPbcivFX2aIvhiewCkv3Rw4SWGxq3AtN-TbfKPD2L4R0QKA3XPc_RImmva7z66yTUJo8bvozFBpwKdHXo5vPaxqB2-UyjVDLrfiCY75WYAkp6P0BF4W3xqoRy8mWUqe5ljCtjlIwbyo2GyR66wkPSOAeUqFl6s0jLycyB7MAaiBbBPOejz27uo2xYdbppL0Bzc7XVrpeE" + }, + "htmlCode": { + "name": "projects/9343387512370733994/files/12809277486904867286", + "downloadUrl": "https://contribution.usercontent.google.com/download?c=CgthaWRhX2NvZGVmeBJ7Eh1hcHBfY29tcGFuaW9uX2dlbmVyYXRlZF9maWxlcxpaCiVodG1sXzAwMDY1YmIyODM5NjZmMWUwNGU3NThiMjE2MDFkYWQxEgsSBxD_iY311x0YAZIBIwoKcHJvamVjdF9pZBIVQhM5MzQzMzg3NTEyMzcwNzMzOTk0&filename=&opi=89354086", + "mimeType": "text/html" + }, + "width": "780", + "height": "2766", + "deviceType": "MOBILE" +} \ No newline at end of file diff --git a/stitch/screens/important-cards.png b/stitch/screens/important-cards.png new file mode 100644 index 0000000..8579622 Binary files /dev/null and b/stitch/screens/important-cards.png differ diff --git a/stitch/screens/most-important.html b/stitch/screens/most-important.html new file mode 100644 index 0000000..d3a7700 --- /dev/null +++ b/stitch/screens/most-important.html @@ -0,0 +1,722 @@ + + + +
+ +
+ +
+
+
+ +
+
+

All Sorted Values

+100 Cards +
+

+ A bird’s-eye view of your 100 cards distributed across the 5 reflective piles. Review your intuition or gently rebalance. +

+ +
+
+ + + Distribution Profile + +8 Core Nominees +
+ +
+
+
+
+
+
+
+
+P5: 8 +P4: 18 +P3: 32 +P2: 24 +P1: 18 +
+
+
+ +
+
+search + + +
+
+ + + +
+ +
+
+
+5 +
+

Most Important

+
+
+8 cards +
+ +
+ +
+
+

Authenticity

+ +
+

+ Living in genuine alignment with inner truth and speaking without pretense or performance. +

+
+ +
+
+

Courage

+ +
+

+ Stepping directly into the unknown and acting in alignment with what is right, despite palpable fear. +

+
+ +
+
+

Compassion

+ +
+

+ Meeting suffering—in myself and others—with a gentle, unwavering posture of mercy and assistance. +

+
+ +
+
+

Freedom

+ +
+

+ Possessing agency over my time, movement, and life path without undue coercion or arbitrary constraints. +

+
+ +
+
+

Curiosity

+ +
+

+ Approaching life, people, and puzzles with wonder, humility, and an open thirst for understanding. +

+
+ +
+
+

Integrity

+ +
+

+ Upholding moral wholeness and consistency in character when nobody is watching. +

+
+ +
+
+

Connection

+ +
+

+ Fostering deep mutual bonds and empathy where souls are seen, understood, and cherished. +

+
+ +
+
+

Wisdom

+ +
+

+ Synthesizing deep experience and discernment into mindful, centered action. +

+
+
+
+ +
+
+
+4 +
+

Very Important

+

Strong priorities shaping everyday rhythms

+
+
+18 cards +
+
+
+ + + + + + + + + + + + + + + + + + + +
+
+Tap chip arrow to promote to Most Important +
+
+
+ +
+
+
+3 +
+

Important

+

Meaningful aspects kept in calm balance

+
+
+32 cards +
+
+
+Security +Order +Adventure +Discipline +Patience +Loyalty +Knowledge +Beauty +Playfulness +Solitude +Reliability +Honesty +Openness +Mastery +Intimacy +Balance +Presence +Acceptance +Clarity +Modesty +Vitality +Insight +Contribution +Depth +Cooperation +Resourcefulness +Adaptability +Spontaneity +Family +Community +
+
+
+ +
+
+
+2 +
+

Somewhat Important

+

Present, but rarely leading choices

+
+
+24 cards +
+
+
+Comfort +Tradition +Efficiency +Leisure +Predictability +Novelty +Ambition +Status +Precision +Competition +Thrift +Privacy +Recognition +Power +Popularity +Conformity +Charm +Intensity +Pleasure +Influence +Formality +Thrill +Reputation +Speed +
+
+
+ +
+
+
+1 +
+

Not Important

+

Consciously released from my life compass

+
+
+18 cards +
+
+
+Wealth +Fame +Authority +Luxury +Winning +Glamour +Perfection +Control +Hierarchy +Ritual +Dominance +Appearance +Acquisition +Strictness +Exclusivity +Superiority +Pride +Publicity +
+
+
+ + +
+ + +
+ + + +
+
+check_circle +Card moved +
+
+
\ No newline at end of file diff --git a/stitch/screens/most-important.json b/stitch/screens/most-important.json new file mode 100644 index 0000000..7602157 --- /dev/null +++ b/stitch/screens/most-important.json @@ -0,0 +1,16 @@ +{ + "name": "projects/9343387512370733994/screens/73272c6039864978bcec444c7df2973f", + "title": "Values Sort \u2014 Most Important (with Demote Drawer)", + "screenshot": { + "name": "projects/9343387512370733994/screens/73272c6039864978bcec444c7df2973f/fileEntries/screenshot", + "downloadUrl": "https://lh3.googleusercontent.com/aida/AEtjO1Xhu39G6wcAy9suH2BL28jVCSboZ_ydBY69b4vlY9-gtnbu66RyRiHaRDF8MkiSVfcjYFtlIVfpFgyN856OBgA4qenmijh6Uii1vIm22lmUNWElTOI1GSSUyOF_NMNLN7N3HKNwLY6pc0PlrUBoB--DVbW6-RcnqfBG-CIAFWWzuOn0YEXUIbLzFLfecIwxJbbHEblBtC8m7q0RR140TUxcbtxtzHRX_km3es8xnlabSjh1DMxb3K1Efg" + }, + "htmlCode": { + "name": "projects/9343387512370733994/screens/73272c6039864978bcec444c7df2973f/fileEntries/html", + "downloadUrl": "https://contribution.usercontent.google.com/download?c=CgthaWRhX2NvZGVmeBJ7Eh1hcHBfY29tcGFuaW9uX2dlbmVyYXRlZF9maWxlcxpaCiVodG1sXzAwMDY1YmIyNzZkYjg4ZjgwNWYxMzVmZTYyMDcyNzBkEgsSBxD_iY311x0YAZIBIwoKcHJvamVjdF9pZBIVQhM5MzQzMzg3NTEyMzcwNzMzOTk0&filename=&opi=89354086", + "mimeType": "text/html" + }, + "width": "780", + "height": "6564", + "deviceType": "MOBILE" +} \ No newline at end of file diff --git a/stitch/screens/most-important.png b/stitch/screens/most-important.png new file mode 100644 index 0000000..120e28f Binary files /dev/null and b/stitch/screens/most-important.png differ diff --git a/stitch/screens/practice-hub.html b/stitch/screens/practice-hub.html new file mode 100644 index 0000000..01973eb --- /dev/null +++ b/stitch/screens/practice-hub.html @@ -0,0 +1,191 @@ + +
+ +
+
+ +Values Sort Hub +
+ +
+ +
+
+spa + +
+

Your values landscape

+ +
+ +
stars

Your Core Values

The foundational principles selected as your highest inner compass:

1 Authenticity2 Courage3 Compassion4 Freedom5 Curiosity6 Integrity7 Connection8 Wisdom
+ +
+
+ + +
+ + + + + + +
+ +
+ +
+ +
+
+ + +
+
+ +
+
+ +
+
+
+hourglass_empty +
+
+

Reset Entire Practice?

+Clean slate contemplation +
+
+

+ This deliberate action will clear all 5 categorized piles, your prioritized 1–8 hierarchy, and stored reflection notes. You will begin this mindful exploration from the initial deck. +

+
+lightbulb + + Tip: You can pause here and revisit your values whenever you need clarity in daily choices without resetting. + +
+ +
+ + +
+
+
+
+
+ + \ No newline at end of file diff --git a/stitch/screens/practice-hub.json b/stitch/screens/practice-hub.json new file mode 100644 index 0000000..57f8633 --- /dev/null +++ b/stitch/screens/practice-hub.json @@ -0,0 +1,16 @@ +{ + "name": "projects/9343387512370733994/screens/7247b606c08a44ef906836e5ab250fd1", + "title": "Values Sort \u2014 Practice Hub", + "screenshot": { + "name": "projects/9343387512370733994/files/7801972050289016175", + "downloadUrl": "https://lh3.googleusercontent.com/aida/AEtjO1UHEdAYHIX_yrZnosUKgBf2xJF0wQL3zTavsKvxg0OSr8wi6CA62NhoBaFMmJGpz7onY3fJBqEfn0FLm1DARm7R4FJ1Kvh-yLyufytn_LW9IJhT_HgxFUtYHE2lLfLUw0BK3O39haKwmhTXdWNkTAdlWoXmJZNRYACXJ6uXbfbXPvQsLtpT5LaQgBu30F_KmlrUqjUtsxxtzNzTEUF-GZhO8Hi-B5weq6pQ0aAppHrH13K3uxb0j6VwSiE" + }, + "htmlCode": { + "name": "projects/9343387512370733994/files/3785570347874661052", + "downloadUrl": "https://contribution.usercontent.google.com/download?c=CgthaWRhX2NvZGVmeBJ7Eh1hcHBfY29tcGFuaW9uX2dlbmVyYXRlZF9maWxlcxpaCiVodG1sXzAwMDY1YmI0MDNhMzc1YWIwNWYxMjlkYjE3MDRmY2VkEgsSBxD_iY311x0YAZIBIwoKcHJvamVjdF9pZBIVQhM5MzQzMzg3NTEyMzcwNzMzOTk0&filename=&opi=89354086", + "mimeType": "text/html" + }, + "width": "780", + "height": "3234", + "deviceType": "MOBILE" +} \ No newline at end of file diff --git a/stitch/screens/practice-hub.png b/stitch/screens/practice-hub.png new file mode 100644 index 0000000..3d98e8e Binary files /dev/null and b/stitch/screens/practice-hub.png differ diff --git a/stitch/screens/rank-order.html b/stitch/screens/rank-order.html new file mode 100644 index 0000000..10509ee --- /dev/null +++ b/stitch/screens/rank-order.html @@ -0,0 +1,547 @@ + +
+ +
+
+
+ +Stage 3 of 4 · Prioritization +
+ +
+ +
+
+
+
+
+
+
+

Order Your Core Values

+

+ Drag or reorder your finalists. Let #1 represent your non-negotiable value.

+
+
+ + + +
+ +
+
+
+
1
+ +
starPrimary North Star
+
+ + +
+drag_indicator +
+
+
+
+

Authenticity

+

+ Living in honest alignment with who you are, without continuous performance or self-betrayal. +

+
+
+Pillar: Self-Alignment + +
+
+ + + +
+
+
+
2
+
+
+ + +
+drag_indicator +
+
+
+
+

Compassion

+

+ Meeting human frailty with gentleness, extending warmth before judgment. +

+
+
+Pillar: Relational + +
+
+ +
+
+
+
3
+
+
+ + +
+drag_indicator +
+
+
+
+

Freedom

+

+ Sovereignty over one’s calendar, bodily presence, and mental headspace. +

+
+
+Pillar: Independence + +
+
+ +
+
+
+
4
+
+
+ + +
+drag_indicator +
+
+
+
+

Curiosity

+

+ An open question kept alive without the desperate hurry to conclude. +

+
+
+Pillar: Growth + +
+
+ +
+
+
+
5
+
+
+ + +
+drag_indicator +
+
+
+
+

Integrity

+

+ Wholeness in private conduct; matching private conviction with public stance. +

+
+
+Pillar: Alignment + +
+
+
+
+
6
+Secondary Anchor +
+
+ + +
+drag_indicator +
+
+
+
+

Courage

+

+ Entering the uncertain room, speaking the tender truth despite physical trembling. +

+
+
+Pillar: Action + +
+
+ +
+
+
+
7
+
+
+ + +
+drag_indicator +
+
+
+
+

Connection

+

+ Deep, unhurried presence that allows another soul to feel seen and held. +

+
+
+Pillar: Relational + +
+
+ +
+
+
+
8
+
+
+ + +
+drag_indicator +
+
+
+
+

Creativity

+

+ The quiet joy of shaping raw experience into form, word, or tangible craft. +

+
+
+Pillar: Self-Expression + +
+
+
+ + + +
+
+psychology_alt +
+
+

Gentle Inquiry

+

+ Notice where inner resistance surfaces. Are you ranking what you truly value, or what you feel you should value for others? +

+
+
+ +
+
+

Stage Insight

+

+ "When values conflict under pressure, #1 determines your immediate direction." +

+
+
+ +
+
+ +
+ + +
+ + + + + + +
+check_circle +New hierarchy saved +
+
+
+ + \ No newline at end of file diff --git a/stitch/screens/rank-order.json b/stitch/screens/rank-order.json new file mode 100644 index 0000000..0a876c2 --- /dev/null +++ b/stitch/screens/rank-order.json @@ -0,0 +1,16 @@ +{ + "name": "projects/9343387512370733994/screens/07a9fc8b113c479db7ece5398a017a9e", + "title": "Values Sort \u2014 Rank Order Top Values", + "screenshot": { + "name": "projects/9343387512370733994/files/1597732408047210864", + "downloadUrl": "https://lh3.googleusercontent.com/aida/AEtjO1WpFA576TA5wf1HnZIoIBm8m5-S0M9LlUvAz8ujVYmbkVydsAdUAmtOpQKsY9nixUIwZEd5bqrDTe6ITlJfFFRhDRxkehFVmgcdAZksW9L89pME6e2gTYuO-qqFQKwD-XAXg_PDunjOg8XZeYs5p1dIVenclfxkXwSQ4Y0IIDTHtIMONRE4S07sHY7skqnnL-f2vmbEBg3ah1WyjcLXIUFIL4AINmYK-vTMCxwwsQ_ev3qhq909m7CdoQ" + }, + "htmlCode": { + "name": "projects/9343387512370733994/files/15103438974169067770", + "downloadUrl": "https://contribution.usercontent.google.com/download?c=CgthaWRhX2NvZGVmeBJ7Eh1hcHBfY29tcGFuaW9uX2dlbmVyYXRlZF9maWxlcxpaCiVodG1sXzAwMDY1YmIyY2VlNWY5MzgwMjNiZWU1MjMxMjVjMzFhEgsSBxD_iY311x0YAZIBIwoKcHJvamVjdF9pZBIVQhM5MzQzMzg3NTEyMzcwNzMzOTk0&filename=&opi=89354086", + "mimeType": "text/html" + }, + "width": "780", + "height": "4408", + "deviceType": "MOBILE" +} \ No newline at end of file diff --git a/stitch/screens/rank-order.png b/stitch/screens/rank-order.png new file mode 100644 index 0000000..7dbfc7f Binary files /dev/null and b/stitch/screens/rank-order.png differ diff --git a/stitch/screens/reflect-top-values.html b/stitch/screens/reflect-top-values.html new file mode 100644 index 0000000..659ef70 --- /dev/null +++ b/stitch/screens/reflect-top-values.html @@ -0,0 +1,299 @@ + +
+ +
+
+ + Step 5 of 5 · Deep Reflection + + +spa + Unrushed Practice + +
+

Your Core Values

+ +
+ +
+
+Top 5 Core Anchors +3 of 4 answered +
+
+ + + + + +
+
+ +
+
+
+ +

+ Authenticity +

+
+ +
+

+ "Living in genuine alignment with inner truth and speaking without pretense or performance." +

+ +
+ +
+ +
+ +
+
+

+ To me, authenticity is the quiet permission to stop curating who I am for other people's approval. It means letting my outward actions and words match what is truly happening inside, even when that makes me feel vulnerable or imperfect. +

+
+
+ +
+
+
+ +
+ + +
+ +
+ +
+
+ + +
+
+ +
+
+
+ +
+ +
+
+ +
+ + 0 words + +
+
+
+ +
+
+
+
+ +
+
+
+explore +
+
+Your Life Compass is Taking Shape +14 of 20 total reflections recorded +
+
+ +
+ + + + +
+ + \ No newline at end of file diff --git a/stitch/screens/reflect-top-values.json b/stitch/screens/reflect-top-values.json new file mode 100644 index 0000000..93923dd --- /dev/null +++ b/stitch/screens/reflect-top-values.json @@ -0,0 +1,16 @@ +{ + "name": "projects/9343387512370733994/screens/3064045350829608185", + "title": "Values Sort \u2014 Reflect on Top Values", + "screenshot": { + "name": "projects/9343387512370733994/files/3064045350829606144", + "downloadUrl": "https://lh3.googleusercontent.com/aida/AEtjO1Uwhwbp12i_K453L9y11FLMvbRC4yDVlY0fdRpaqI4rBUKKwEFBEzO2fbvVyKER5fnk5X11ceImXKtx6h37TpNJIYr6CZNuNhJc1LNKbOGiV_CO3ee7AVc0nOez48-19Q7vf8_H6Q1DlfjZj3B3v2X23in3GCgyxo5rAa-NsMkS9_G4oCvnWSIioTblp-viQRuJhyq3jIlEldpfZPkU5FQh7hr6lH9RMVXv2Pdy_sviQJK_4R2KOQJxAUc" + }, + "htmlCode": { + "name": "projects/9343387512370733994/files/3064045350829608199", + "downloadUrl": "https://contribution.usercontent.google.com/download?c=CgthaWRhX2NvZGVmeBJ7Eh1hcHBfY29tcGFuaW9uX2dlbmVyYXRlZF9maWxlcxpaCiVodG1sXzAwMDY1YmIyOWJmZTJmY2UwOTY4YTAzNjNmMGQ1ODhmEgsSBxD_iY311x0YAZIBIwoKcHJvamVjdF9pZBIVQhM5MzQzMzg3NTEyMzcwNzMzOTk0&filename=&opi=89354086", + "mimeType": "text/html" + }, + "width": "780", + "height": "3384", + "deviceType": "MOBILE" +} \ No newline at end of file diff --git a/stitch/screens/reflect-top-values.png b/stitch/screens/reflect-top-values.png new file mode 100644 index 0000000..bc96c48 Binary files /dev/null and b/stitch/screens/reflect-top-values.png differ diff --git a/stitch/screens/reflection-journal.html b/stitch/screens/reflection-journal.html new file mode 100644 index 0000000..c3a4757 --- /dev/null +++ b/stitch/screens/reflection-journal.html @@ -0,0 +1,391 @@ + +
+ +
+
+ + Step 5 of 5 · Deep Reflection + + +spa + Unrushed Practice + +
+

Your Core Values

+
+ +
+
+Top 5 Core Anchors +3 of 4 answered +
+
+ + + + + +
+
+ +
+
+
+

+ Authenticity +

+
+
+

+ "Living in genuine alignment with inner truth and speaking without pretense or performance." +

+
+ +
+ +
+
+
+ +check + +
+
+Answered +· Prompt 1 +
+

+ What does this value mean to you? +

+
+
+open_in_full +
+
+
+

+ To me, authenticity is the quiet permission to stop curating who I am for other people's approval. It means letting my outward actions and words match what is truly happening inside, even when that makes me feel vulnerable or imperfect. +

+
+
+ +
+
+
+ +
+
+
+ +check + +
+
+Answered +· Prompt 2 +
+

+ Why did you choose this as important? +

+
+
+open_in_full +
+ +
+ +
+
+
+ +edit_note + +
+
+In Progress +· Prompt 3 +
+

+ How have you shown this value in your daily life? +

+
+
+open_in_full +
+
+
+

"Recently, during a project retrospective, I spoke honestly about feeling overwhelmed instead of nodding along like everything was fine..."

+
+
+ +mode_edit Tap to expand & journal + + +
+
+
+ +
+
+
+ +add + +
+
+Unanswered +· Prompt 4 +
+

+ In what ways could you be even more true to it? +

+
+
+edit_square +
+
+

Notice where you still mask, hold back, or seek quiet convenience over your core alignment.

+
+ +
+
+
+
+ +
+
+
+explore +
+
+Your Life Compass is Taking Shape +14 of 20 total reflections recorded +
+
+ +
+ + + +
+ +
+
+ +
+ +Prompt 3 of 4 +
+ +
+
+ +
+ +
+
+ + Reflecting on #1 Authenticity + + +
+

+ How have you shown this value in your daily life? +

+ + +
+ +
+ +
+ + 33 words · 214 characters + + +spa Mindful focus mode + +
+
+
+ +
+
+ + +
+
+
+ + +
+ + + \ No newline at end of file diff --git a/stitch/screens/reflection-journal.json b/stitch/screens/reflection-journal.json new file mode 100644 index 0000000..de141ee --- /dev/null +++ b/stitch/screens/reflection-journal.json @@ -0,0 +1,16 @@ +{ + "name": "projects/9343387512370733994/screens/7bb00b6022b14dd9a687b9a31ec6b1f5", + "title": "Values Sort \u2014 Reflection (Full-Screen Journal Drawer)", + "screenshot": { + "name": "projects/9343387512370733994/files/15389870381633265971", + "downloadUrl": "https://lh3.googleusercontent.com/aida/AEtjO1UAeYCnBIII639-PoE0tFfV3LHW2yIsQ2XGm0L6YPETkrpzkMplK_QGvg-8F8PSLt_oJCS-3Uajl851y6H1IkywclVOp_OG34ANFKKEmKIyGFxXYg-XXlWWlanu_22zhpb_lhiO4auBOIFSxwwFc2NbU3b6qlsAIlBGONtInErxesAiJILt6j1yKKM1OkGJIrh4sWabbDoAfBNaJlu_hDywInGjs4j-d6SZOEvzgunjmwSjLStHmjuQoVk" + }, + "htmlCode": { + "name": "projects/9343387512370733994/files/2406486854586640137", + "downloadUrl": "https://contribution.usercontent.google.com/download?c=CgthaWRhX2NvZGVmeBJ7Eh1hcHBfY29tcGFuaW9uX2dlbmVyYXRlZF9maWxlcxpaCiVodG1sXzAwMDY1YmIyYWE0ZDI3M2IwMjNiZWU1MjMxMjVjMzFhEgsSBxD_iY311x0YAZIBIwoKcHJvamVjdF9pZBIVQhM5MzQzMzg3NTEyMzcwNzMzOTk0&filename=&opi=89354086", + "mimeType": "text/html" + }, + "width": "780", + "height": "3596", + "deviceType": "MOBILE" +} \ No newline at end of file diff --git a/stitch/screens/reflection-journal.png b/stitch/screens/reflection-journal.png new file mode 100644 index 0000000..61da36f Binary files /dev/null and b/stitch/screens/reflection-journal.png differ diff --git a/stitch/screens/welcome.html b/stitch/screens/welcome.html new file mode 100644 index 0000000..cff0349 --- /dev/null +++ b/stitch/screens/welcome.html @@ -0,0 +1,223 @@ + +
+ +
+
+Values Sort Emblem +VALUES SORT +
+
+ +
+ +
+
+ +
+Inquiry +

Curiosity

+

Seeking wonder, exploring questions without immediate answers.

+
+ +
+Belonging +

Connection

+

Nurturing deep kinship and seeing others as they truly are.

+
+ +
+
+Core Ground +spa +
+

Authenticity

+

Living in honest alignment with who you are, without performance.

+
+ + + +
+
+
+
+ +
+

+ Discover what matters most to you +

+

+ Through a gentle, guided card-sort practice, clarify your intrinsic values and illuminate the principles that guide your everyday decisions. +

+ +
+
+schedule +10–15 min +
+• +
+pace +Self-paced +
+• +
+lock +Private & reflective +
+
+
+ +
+ + +
+
+ + +
+ \ No newline at end of file diff --git a/stitch/screens/welcome.json b/stitch/screens/welcome.json new file mode 100644 index 0000000..8e59298 --- /dev/null +++ b/stitch/screens/welcome.json @@ -0,0 +1,16 @@ +{ + "name": "projects/9343387512370733994/screens/e3940f39a073450299cb3024914064c6", + "title": "Values Sort \u2014 Welcome (with Guide Drawer)", + "screenshot": { + "name": "projects/9343387512370733994/files/13321085336226512739", + "downloadUrl": "https://lh3.googleusercontent.com/aida/AEtjO1XxoE8CbYmmotxXpSsZdC3wUE-E_xSas8nAxp_8GIUsaVeT2TjBeYVAwZM64nKp2KiGgcv4OGIq2noOJzxbWuzc-L2mitY9iU_fmiwGqgff0u7RpyjXlC0eRZlhvcRybK_E_dFQ_b8Q3GBOP06crg0MlaWtIPMS9WT-WIwmui7yJkqcEDsjlJlU8reI8HinfJtzvxK1gfOfEWbLDV5gPc7T_ypo6WR-fM3lZ35FSrkH4NQoX2e-d1-DlO0" + }, + "htmlCode": { + "name": "projects/9343387512370733994/files/24632818105687442", + "downloadUrl": "https://contribution.usercontent.google.com/download?c=CgthaWRhX2NvZGVmeBJ7Eh1hcHBfY29tcGFuaW9uX2dlbmVyYXRlZF9maWxlcxpaCiVodG1sXzAwMDY1YmIyNTQwYmQ5N2IwMjNiZTk5NjE2MDEzZDgzEgsSBxD_iY311x0YAZIBIwoKcHJvamVjdF9pZBIVQhM5MzQzMzg3NTEyMzcwNzMzOTk0&filename=&opi=89354086", + "mimeType": "text/html" + }, + "width": "780", + "height": "1768", + "deviceType": "MOBILE" +} \ No newline at end of file diff --git a/stitch/screens/welcome.png b/stitch/screens/welcome.png new file mode 100644 index 0000000..e3f358b Binary files /dev/null and b/stitch/screens/welcome.png differ diff --git a/test/widget_test.dart b/test/widget_test.dart index 9793445..0992d73 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,66 +1,79 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:google_fonts/google_fonts.dart'; import 'package:myapp/main.dart'; -import 'package:myapp/models/item.dart'; -import 'package:myapp/services/item_repository.dart'; +import 'package:myapp/models/value_card.dart'; +import 'package:myapp/models/value_pile.dart'; +import 'package:myapp/providers/values_sort_provider.dart'; import 'package:myapp/services/storage_service.dart'; import 'package:myapp/utils/constants.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + GoogleFonts.config.allowRuntimeFetching = false; - Future pumpApp(WidgetTester tester) async { + testWidgets('splash shows Values Sort then opens welcome', (tester) async { SharedPreferences.setMockInitialValues({}); final storage = StorageService(); await storage.init(); - await tester.pumpWidget( - MyApp( - storage: storage, - repository: ItemRepository(), - ), - ); - } - - testWidgets('splash shows the app name then opens Home', (tester) async { - await pumpApp(tester); + await tester.pumpWidget(MyApp(storage: storage)); expect(find.text(kAppName), findsWidgets); await tester.pump(kSplashDuration); - await tester.pump(const Duration(milliseconds: 450)); - await tester.pump(const Duration(milliseconds: 350)); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 200)); - expect(find.text('Home'), findsWidgets); - expect(find.text('Featured items'), findsOneWidget); - expect(find.text('Search'), findsOneWidget); - expect(find.text('Profile'), findsOneWidget); - expect(find.text('Settings'), findsOneWidget); + expect(find.text('Begin Reflection'), findsOneWidget); + expect(find.textContaining('Discover what matters'), findsOneWidget); }); - testWidgets('tapping a list card opens the detail screen', (tester) async { - await pumpApp(tester); - await tester.pump(kSplashDuration); - await tester.pump(const Duration(milliseconds: 450)); - await tester.pump(const Duration(milliseconds: 350)); - - await tester.tap(find.text('Material 3 surfaces')); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 400)); - - expect(find.text('Details'), findsOneWidget); - expect(find.text('Back to list'), findsOneWidget); - }); - - test('Item.fromJson maps a JSONPlaceholder post', () { - final item = Item.fromJson({ - 'id': 3, - 'title': 'hello world', - 'body': 'First sentence. Second sentence.', + test('ValueCard and ValuePile helpers', () { + final card = ValueCard.fromJson({ + 'id': 'v1', + 'title': 'Courage', + 'description': 'Acting despite fear.', + 'domain': 'Agency', }); + expect(card.title, 'Courage'); + expect(ValuePile.mostImportant.rank, 5); + expect(ValuePile.notImportant.shortLabel, 'Not'); + }); - expect(item.id, 'remote-3'); - expect(item.title, 'Hello world'); - expect(item.subtitle, 'First sentence.'); - expect(item.category, ItemCategory.lifestyle); + testWidgets('sort progress persists across controller reloads', (tester) async { + SharedPreferences.setMockInitialValues({}); + final storage = StorageService(); + await storage.init(); + + const seed = [ + ValueCard( + id: 'v001', + title: 'Acceptance', + description: 'To be accepted as I am', + ), + ValueCard( + id: 'v002', + title: 'Accuracy', + description: 'To be accurate in my opinions and beliefs', + ), + ]; + + final first = ValuesSortController(storage: storage); + await first.load(seedDeck: seed); + first.beginPractice(); + final cardId = first.currentCard!.id; + first.assignCurrent(ValuePile.mostImportant); + await tester.pump(); + + expect(storage.sortSessionJson, isNotNull); + + first.dispose(); + + final second = ValuesSortController(storage: storage); + await second.load(seedDeck: seed); + expect(second.stage, SortStage.sorting); + expect(second.assignments[cardId], ValuePile.mostImportant); + expect(second.sortedCount, 1); + second.dispose(); }); }