From 447266847453599e7c45d8ff7786410ba473fe59 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Sun, 15 Feb 2026 15:12:23 +0100 Subject: [PATCH] Add persisted local state and baseline CRUD flows --- docs/progress.md | 25 + lib/features/dashboard/dashboard_view.dart | 153 +++--- lib/features/local/local_models.dart | 292 +++++++++++ lib/features/local/local_repository.dart | 260 ++++++---- lib/features/moments/moments_view.dart | 294 +++++++---- lib/features/people/people_view.dart | 486 ++++++++++++++---- lib/features/signals/signals_controller.dart | 27 +- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 56 ++ pubspec.yaml | 1 + .../features/local/local_repository_test.dart | 73 +++ test/widget_test.dart | 5 + 12 files changed, 1324 insertions(+), 350 deletions(-) create mode 100644 test/features/local/local_repository_test.dart diff --git a/docs/progress.md b/docs/progress.md index dc9058b..7dbe9d3 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -2,6 +2,31 @@ Updated: 2026-02-15 +## Latest Milestone (2026-02-15): Local Persistence + CRUD Baseline + +Implemented the first real local data foundation pass (replacing static demo-only reads): + +- Added persisted local state using `shared_preferences`: + - `lib/features/local/local_repository.dart` + - schema key/version scaffold + seed fallback + persistence logic +- Upgraded local models with serialization/copy methods: + - `lib/features/local/local_models.dart` +- Added local CRUD operations: + - people: add, edit, delete + - moments: add, delete + - dashboard tasks: done/undone toggle +- Wired views to async persisted state: + - `DashboardView`, `PeopleView`, `MomentsView` +- Updated signals fallback to read from persisted local state: + - `lib/features/signals/signals_controller.dart` +- Added local repository tests: + - `test/features/local/local_repository_test.dart` + +Validation for this milestone: + +- `flutter analyze` -> pass +- `flutter test` -> pass + ## Workflow Rule - After every sensible change, the final step is a Git commit with a clear message. diff --git a/lib/features/dashboard/dashboard_view.dart b/lib/features/dashboard/dashboard_view.dart index 97c7717..3d3e3b4 100644 --- a/lib/features/dashboard/dashboard_view.dart +++ b/lib/features/dashboard/dashboard_view.dart @@ -10,66 +10,91 @@ class DashboardView extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final LocalRepository repository = ref.watch(localRepositoryProvider); - final DashboardSummary summary = repository.summary(); - final List tasks = repository.tasks(); + final AsyncValue localData = ref.watch( + localRepositoryProvider, + ); - return SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(28, 24, 28, 36), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Today', style: Theme.of(context).textTheme.headlineMedium), - const SizedBox(height: 8), - Text( - 'Focus on consistency over intensity. Small moments compound.', - style: Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), - ), - const SizedBox(height: 22), - Wrap( - spacing: 16, - runSpacing: 16, + return localData.when( + data: (LocalDataState data) { + final DashboardSummary summary = data.summary(); + final List tasks = data.tasks; + + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(28, 24, 28, 36), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _StatCard( - label: 'Active People', - value: '${summary.activePeople}', - accent: const Color(0xFF16B8CA), + Text('Today', style: Theme.of(context).textTheme.headlineMedium), + const SizedBox(height: 8), + Text( + 'Focus on consistency over intensity. Small moments compound.', + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), ), - _StatCard( - label: 'Upcoming Plans', - value: '${summary.upcomingPlans}', - accent: const Color(0xFF2B7FFF), + const SizedBox(height: 22), + Wrap( + spacing: 16, + runSpacing: 16, + children: [ + _StatCard( + label: 'Active People', + value: '${summary.activePeople}', + accent: const Color(0xFF16B8CA), + ), + _StatCard( + label: 'Upcoming Plans', + value: '${summary.upcomingPlans}', + accent: const Color(0xFF2B7FFF), + ), + _StatCard( + label: 'Pending Ideas', + value: '${summary.pendingIdeas}', + accent: const Color(0xFFFFA447), + ), + _StatCard( + label: 'Weekly Rhythm', + value: '${summary.weeklyConsistency}%', + accent: const Color(0xFF30B66A), + ), + ], ), - _StatCard( - label: 'Pending Ideas', - value: '${summary.pendingIdeas}', - accent: const Color(0xFFFFA447), - ), - _StatCard( - label: 'Weekly Rhythm', - value: '${summary.weeklyConsistency}%', - accent: const Color(0xFF30B66A), + const SizedBox(height: 24), + FrostedCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Next Moves', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 14), + ...tasks.map( + (DashboardTask task) => _TaskRow( + task: task, + onToggleDone: () { + ref + .read(localRepositoryProvider.notifier) + .toggleTaskDone(task.id); + }, + ), + ), + ], + ), ), ], ), - const SizedBox(height: 24), - FrostedCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Next Moves', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 14), - ...tasks.map((DashboardTask task) => _TaskRow(task: task)), - ], - ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (Object error, StackTrace stackTrace) { + return Center( + child: Text( + 'Could not load local data.', + style: Theme.of(context).textTheme.bodyLarge, ), - ], - ), + ); + }, ); } } @@ -124,9 +149,10 @@ class _StatCard extends StatelessWidget { } class _TaskRow extends StatelessWidget { - const _TaskRow({required this.task}); + const _TaskRow({required this.task, required this.onToggleDone}); final DashboardTask task; + final VoidCallback onToggleDone; @override Widget build(BuildContext context) { @@ -141,13 +167,16 @@ class _TaskRow extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - margin: const EdgeInsets.only(top: 2), - width: 10, - height: 10, - decoration: const BoxDecoration( - color: Color(0xFF1AB6C8), - shape: BoxShape.circle, + InkWell( + onTap: onToggleDone, + borderRadius: BorderRadius.circular(20), + child: Icon( + task.done + ? Icons.check_circle_rounded + : Icons.radio_button_unchecked_rounded, + color: task.done + ? const Color(0xFF1D9C66) + : const Color(0xFF1AB6C8), ), ), const SizedBox(width: 12), @@ -157,7 +186,9 @@ class _TaskRow extends StatelessWidget { children: [ Text( task.title, - style: Theme.of(context).textTheme.titleMedium, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + decoration: task.done ? TextDecoration.lineThrough : null, + ), ), const SizedBox(height: 4), Text( diff --git a/lib/features/local/local_models.dart b/lib/features/local/local_models.dart index de42ba3..f2e725b 100644 --- a/lib/features/local/local_models.dart +++ b/lib/features/local/local_models.dart @@ -1,3 +1,8 @@ +// ignore_for_file: sort_constructors_first + +import 'package:flutter/foundation.dart'; + +@immutable class PersonProfile { const PersonProfile({ required this.id, @@ -16,8 +21,55 @@ class PersonProfile { final DateTime nextMoment; final List tags; final String notes; + + PersonProfile copyWith({ + String? id, + String? name, + String? relationship, + int? affinityScore, + DateTime? nextMoment, + List? tags, + String? notes, + }) { + return PersonProfile( + id: id ?? this.id, + name: name ?? this.name, + relationship: relationship ?? this.relationship, + affinityScore: affinityScore ?? this.affinityScore, + nextMoment: nextMoment ?? this.nextMoment, + tags: tags ?? this.tags, + notes: notes ?? this.notes, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'relationship': relationship, + 'affinityScore': affinityScore, + 'nextMoment': nextMoment.toUtc().toIso8601String(), + 'tags': tags, + 'notes': notes, + }; + } + + factory PersonProfile.fromJson(Map json) { + return PersonProfile( + id: json['id'] as String, + name: json['name'] as String, + relationship: json['relationship'] as String, + affinityScore: (json['affinityScore'] as num).toInt(), + nextMoment: DateTime.parse(json['nextMoment'] as String).toLocal(), + tags: (json['tags'] as List) + .map((dynamic tag) => '$tag') + .toList(), + notes: json['notes'] as String, + ); + } } +@immutable class RelationshipMoment { const RelationshipMoment({ required this.id, @@ -34,22 +86,102 @@ class RelationshipMoment { final String summary; final DateTime at; final String type; + + RelationshipMoment copyWith({ + String? id, + String? personId, + String? title, + String? summary, + DateTime? at, + String? type, + }) { + return RelationshipMoment( + id: id ?? this.id, + personId: personId ?? this.personId, + title: title ?? this.title, + summary: summary ?? this.summary, + at: at ?? this.at, + type: type ?? this.type, + ); + } + + Map toJson() { + return { + 'id': id, + 'personId': personId, + 'title': title, + 'summary': summary, + 'at': at.toUtc().toIso8601String(), + 'type': type, + }; + } + + factory RelationshipMoment.fromJson(Map json) { + return RelationshipMoment( + id: json['id'] as String, + personId: json['personId'] as String, + title: json['title'] as String, + summary: json['summary'] as String, + at: DateTime.parse(json['at'] as String).toLocal(), + type: json['type'] as String, + ); + } } +@immutable class DashboardTask { const DashboardTask({ + required this.id, required this.title, required this.description, required this.when, this.done = false, }); + final String id; final String title; final String description; final DateTime when; final bool done; + + DashboardTask copyWith({ + String? id, + String? title, + String? description, + DateTime? when, + bool? done, + }) { + return DashboardTask( + id: id ?? this.id, + title: title ?? this.title, + description: description ?? this.description, + when: when ?? this.when, + done: done ?? this.done, + ); + } + + Map toJson() { + return { + 'id': id, + 'title': title, + 'description': description, + 'when': when.toUtc().toIso8601String(), + 'done': done, + }; + } + + factory DashboardTask.fromJson(Map json) { + return DashboardTask( + id: json['id'] as String, + title: json['title'] as String, + description: json['description'] as String, + when: DateTime.parse(json['when'] as String).toLocal(), + done: json['done'] as bool? ?? false, + ); + } } +@immutable class DashboardSummary { const DashboardSummary({ required this.activePeople, @@ -63,3 +195,163 @@ class DashboardSummary { final int pendingIdeas; final int weeklyConsistency; } + +@immutable +class LocalDataState { + const LocalDataState({ + required this.people, + required this.moments, + required this.tasks, + }); + + final List people; + final List moments; + final List tasks; + + LocalDataState copyWith({ + List? people, + List? moments, + List? tasks, + }) { + return LocalDataState( + people: people ?? this.people, + moments: moments ?? this.moments, + tasks: tasks ?? this.tasks, + ); + } + + DashboardSummary summary({DateTime? now}) { + final DateTime baseline = now ?? DateTime.now(); + return DashboardSummary( + activePeople: people.length, + upcomingPlans: people + .where((PersonProfile p) => p.nextMoment.isAfter(baseline)) + .length, + pendingIdeas: people.fold( + 0, + (int acc, PersonProfile person) => + acc + (person.tags.isNotEmpty ? 1 : 0), + ), + weeklyConsistency: moments.isEmpty + ? 0 + : (70 + moments.length * 4).clamp(0, 100), + ); + } + + Map toJson() { + return { + 'people': people + .map((PersonProfile person) => person.toJson()) + .toList(growable: false), + 'moments': moments + .map((RelationshipMoment moment) => moment.toJson()) + .toList(growable: false), + 'tasks': tasks + .map((DashboardTask task) => task.toJson()) + .toList(growable: false), + }; + } + + factory LocalDataState.fromJson(Map json) { + return LocalDataState( + people: (json['people'] as List) + .map( + (dynamic person) => + PersonProfile.fromJson(person as Map), + ) + .toList(growable: false), + moments: (json['moments'] as List) + .map( + (dynamic moment) => + RelationshipMoment.fromJson(moment as Map), + ) + .toList(growable: false), + tasks: (json['tasks'] as List) + .map( + (dynamic task) => + DashboardTask.fromJson(task as Map), + ) + .toList(growable: false), + ); + } + + static LocalDataState seed() { + return LocalDataState( + people: [ + PersonProfile( + id: 'p-ava', + name: 'Ava Hart', + relationship: 'Partner', + affinityScore: 92, + nextMoment: DateTime(2026, 2, 16, 19, 30), + tags: ['coffee', 'books', 'slow mornings'], + notes: 'Prefers thoughtful plans over expensive plans.', + ), + PersonProfile( + id: 'p-jordan', + name: 'Jordan Lee', + relationship: 'Close Friend', + affinityScore: 78, + nextMoment: DateTime(2026, 2, 18, 18, 0), + tags: ['running', 'street food'], + notes: 'Has a race on Sunday; ask how training is going.', + ), + PersonProfile( + id: 'p-mila', + name: 'Mila Stone', + relationship: 'Sister', + affinityScore: 84, + nextMoment: DateTime(2026, 2, 20, 12, 0), + tags: ['plants', 'retro music'], + notes: 'Birthday prep should start this week.', + ), + ], + moments: [ + RelationshipMoment( + id: 'm-1', + personId: 'p-ava', + title: 'Sunday Walk Tradition', + summary: 'Short walk + no-phone hour worked really well.', + at: DateTime(2026, 2, 9, 9, 30), + type: 'ritual', + ), + RelationshipMoment( + id: 'm-2', + personId: 'p-jordan', + title: 'Post-workout Check-in', + summary: 'Shared training playlist and grabbed smoothies.', + at: DateTime(2026, 2, 11, 19, 0), + type: 'support', + ), + RelationshipMoment( + id: 'm-3', + personId: 'p-mila', + title: 'Gift Idea Captured', + summary: 'Found a vintage lamp from her saved style board.', + at: DateTime(2026, 2, 12, 14, 20), + type: 'gift', + ), + ], + tasks: [ + DashboardTask( + id: 't-1', + title: 'Plan Friday dinner with Ava', + description: 'Keep it low-key: ramen + bookstore stop.', + when: DateTime(2026, 2, 16, 17, 0), + ), + DashboardTask( + id: 't-2', + title: 'Send race-day encouragement to Jordan', + description: 'Voice note before 8:00 AM.', + when: DateTime(2026, 2, 17, 7, 30), + ), + DashboardTask( + id: 't-3', + title: 'Finalize Mila birthday shortlist', + description: 'Pick 1 meaningful gift and 1 backup.', + when: DateTime(2026, 2, 18, 20, 0), + ), + ], + ); + } +} diff --git a/lib/features/local/local_repository.dart b/lib/features/local/local_repository.dart index fca318f..be7d889 100644 --- a/lib/features/local/local_repository.dart +++ b/lib/features/local/local_repository.dart @@ -1,105 +1,175 @@ +import 'dart:convert'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:relationship_saver/features/local/local_models.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:uuid/uuid.dart'; -class LocalRepository { - const LocalRepository(); +/// Persisted local data source for offline-first product state. +class LocalRepository extends AsyncNotifier { + static const String _storageKey = 'local_data_state_v1'; + static const String _schemaVersionKey = 'local_data_schema_version'; + static const int _schemaVersion = 1; - List people() { - return [ - PersonProfile( - id: 'p-ava', - name: 'Ava Hart', - relationship: 'Partner', - affinityScore: 92, - nextMoment: DateTime(2026, 2, 16, 19, 30), - tags: ['coffee', 'books', 'slow mornings'], - notes: 'Prefers thoughtful plans over expensive plans.', - ), - PersonProfile( - id: 'p-jordan', - name: 'Jordan Lee', - relationship: 'Close Friend', - affinityScore: 78, - nextMoment: DateTime(2026, 2, 18, 18, 0), - tags: ['running', 'street food'], - notes: 'Has a race on Sunday; ask how training is going.', - ), - PersonProfile( - id: 'p-mila', - name: 'Mila Stone', - relationship: 'Sister', - affinityScore: 84, - nextMoment: DateTime(2026, 2, 20, 12, 0), - tags: ['plants', 'retro music'], - notes: 'Birthday prep should start this week.', - ), - ]; + static const Uuid _uuid = Uuid(); + + @override + Future build() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await _migrateIfNeeded(prefs); + + final String? raw = prefs.getString(_storageKey); + if (raw == null || raw.isEmpty) { + final LocalDataState seeded = LocalDataState.seed(); + await _persist(seeded); + return seeded; + } + + try { + final Map json = jsonDecode(raw) as Map; + return LocalDataState.fromJson(json); + } on FormatException { + final LocalDataState fallback = LocalDataState.seed(); + await _persist(fallback); + return fallback; + } } - List moments() { - return [ - RelationshipMoment( - id: 'm-1', - personId: 'p-ava', - title: 'Sunday Walk Tradition', - summary: 'Short walk + no-phone hour worked really well.', - at: DateTime(2026, 2, 9, 9, 30), - type: 'ritual', - ), - RelationshipMoment( - id: 'm-2', - personId: 'p-jordan', - title: 'Post-workout Check-in', - summary: 'Shared training playlist and grabbed smoothies.', - at: DateTime(2026, 2, 11, 19, 0), - type: 'support', - ), - RelationshipMoment( - id: 'm-3', - personId: 'p-mila', - title: 'Gift Idea Captured', - summary: 'Found a vintage lamp from her saved style board.', - at: DateTime(2026, 2, 12, 14, 20), - type: 'gift', - ), - ]; - } - - List tasks() { - return [ - DashboardTask( - title: 'Plan Friday dinner with Ava', - description: 'Keep it low-key: ramen + bookstore stop.', - when: DateTime(2026, 2, 16, 17, 0), - ), - DashboardTask( - title: 'Send race-day encouragement to Jordan', - description: 'Voice note before 8:00 AM.', - when: DateTime(2026, 2, 17, 7, 30), - ), - DashboardTask( - title: 'Finalize Mila birthday shortlist', - description: 'Pick 1 meaningful gift and 1 backup.', - when: DateTime(2026, 2, 18, 20, 0), - ), - ]; - } - - DashboardSummary summary() { - return DashboardSummary( - activePeople: people().length, - upcomingPlans: people() - .where( - (PersonProfile p) => p.nextMoment.isAfter(DateTime(2026, 2, 14)), - ) - .length, - pendingIdeas: 6, - weeklyConsistency: 82, + Future addPerson({ + required String name, + required String relationship, + required String notes, + required List tags, + DateTime? nextMoment, + }) async { + final LocalDataState current = _requireState(); + final PersonProfile person = PersonProfile( + id: 'p-${_uuid.v4()}', + name: name.trim(), + relationship: relationship.trim(), + affinityScore: 75, + nextMoment: nextMoment ?? DateTime.now().add(const Duration(days: 3)), + tags: tags, + notes: notes.trim(), ); + + final List people = [ + person, + ...current.people, + ]; + await _setState(current.copyWith(people: people)); + } + + Future updatePerson(PersonProfile person) async { + final LocalDataState current = _requireState(); + final List updated = current.people + .map((PersonProfile item) => item.id == person.id ? person : item) + .toList(growable: false); + await _setState(current.copyWith(people: updated)); + } + + Future deletePerson(String personId) async { + final LocalDataState current = _requireState(); + final List people = current.people + .where((PersonProfile person) => person.id != personId) + .toList(growable: false); + final List moments = current.moments + .where((RelationshipMoment moment) => moment.personId != personId) + .toList(growable: false); + + await _setState(current.copyWith(people: people, moments: moments)); + } + + Future addMoment({ + required String personId, + required String summary, + String type = 'capture', + }) async { + final LocalDataState current = _requireState(); + final String normalized = summary.trim(); + if (normalized.isEmpty) { + return; + } + + final String title = _titleFromSummary(normalized); + final RelationshipMoment moment = RelationshipMoment( + id: 'm-${_uuid.v4()}', + personId: personId, + title: title, + summary: normalized, + at: DateTime.now(), + type: type, + ); + + final List moments = [ + moment, + ...current.moments, + ]; + await _setState(current.copyWith(moments: moments)); + } + + Future deleteMoment(String momentId) async { + final LocalDataState current = _requireState(); + final List moments = current.moments + .where((RelationshipMoment moment) => moment.id != momentId) + .toList(growable: false); + await _setState(current.copyWith(moments: moments)); + } + + Future toggleTaskDone(String taskId) async { + final LocalDataState current = _requireState(); + final List tasks = current.tasks + .map( + (DashboardTask task) => + task.id == taskId ? task.copyWith(done: !task.done) : task, + ) + .toList(growable: false); + + await _setState(current.copyWith(tasks: tasks)); + } + + Future _setState(LocalDataState next) async { + state = AsyncData(next); + await _persist(next); + } + + LocalDataState _requireState() { + final LocalDataState? value = state.asData?.value; + if (value == null) { + throw StateError('Local data state is not ready yet'); + } + return value; + } + + Future _persist(LocalDataState state) async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.setString(_storageKey, jsonEncode(state.toJson())); + await prefs.setInt(_schemaVersionKey, _schemaVersion); + } + + Future _migrateIfNeeded(SharedPreferences prefs) async { + final int currentVersion = prefs.getInt(_schemaVersionKey) ?? 0; + if (currentVersion == _schemaVersion) { + return; + } + + if (currentVersion <= 0) { + await prefs.remove(_storageKey); + await prefs.setInt(_schemaVersionKey, _schemaVersion); + return; + } + + // Migration placeholder for future schema versions. + await prefs.setInt(_schemaVersionKey, _schemaVersion); + } + + String _titleFromSummary(String summary) { + final List words = summary.split(RegExp(r'\s+')); + final int take = words.length < 5 ? words.length : 5; + return words.take(take).join(' '); } } -final Provider localRepositoryProvider = - Provider((Ref ref) { - return const LocalRepository(); - }); +final AsyncNotifierProvider +localRepositoryProvider = + AsyncNotifierProvider(LocalRepository.new); diff --git a/lib/features/moments/moments_view.dart b/lib/features/moments/moments_view.dart index 969c584..35488ef 100644 --- a/lib/features/moments/moments_view.dart +++ b/lib/features/moments/moments_view.dart @@ -5,112 +5,214 @@ import 'package:relationship_saver/features/local/local_models.dart'; import 'package:relationship_saver/features/local/local_repository.dart'; import 'package:relationship_saver/features/shared/frosted_card.dart'; -class MomentsView extends ConsumerWidget { +class MomentsView extends ConsumerStatefulWidget { const MomentsView({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { - final LocalRepository repository = ref.watch(localRepositoryProvider); - final List moments = repository.moments(); - final Map peopleById = { - for (final PersonProfile person in repository.people()) person.id: person, - }; + ConsumerState createState() => _MomentsViewState(); +} - return Padding( - padding: const EdgeInsets.fromLTRB(28, 24, 28, 28), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Moments', style: Theme.of(context).textTheme.headlineMedium), - const SizedBox(height: 6), - Text( - 'Capture wins and signals from everyday interactions.', - style: Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), - ), - const SizedBox(height: 16), - FrostedCard( - child: Row( - children: [ - Expanded( - child: TextField( - decoration: InputDecoration( - hintText: - 'Quick capture: what happened, how it felt, what to repeat...', - hintStyle: Theme.of(context).textTheme.bodyMedium - ?.copyWith(color: AppTheme.textSecondary), - border: InputBorder.none, - ), - ), - ), - FilledButton.icon( - onPressed: () {}, - icon: const Icon(Icons.add_rounded), - label: const Text('Add Capture'), - ), - ], - ), - ), - const SizedBox(height: 16), - Expanded( - child: ListView.separated( - itemCount: moments.length, - separatorBuilder: (_, _) => const SizedBox(height: 12), - itemBuilder: (BuildContext context, int index) { - final RelationshipMoment moment = moments[index]; - final PersonProfile? person = peopleById[moment.personId]; - return FrostedCard( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 12, - height: 12, - margin: const EdgeInsets.only(top: 6), - decoration: const BoxDecoration( - color: Color(0xFF1AB6C8), - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - moment.title, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 4), - Text( - '${person?.name ?? 'Unknown'} • ${moment.type.toUpperCase()}', - style: Theme.of(context).textTheme.labelLarge +class _MomentsViewState extends ConsumerState { + final TextEditingController _captureController = TextEditingController(); + String? _selectedPersonId; + + @override + void dispose() { + _captureController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final AsyncValue localData = ref.watch( + localRepositoryProvider, + ); + + return localData.when( + data: (LocalDataState data) { + final List people = data.people; + final List moments = data.moments; + final Map peopleById = { + for (final PersonProfile person in people) person.id: person, + }; + + final String? fallbackPersonId = people.isEmpty + ? null + : people.first.id; + final String? activePerson = + people.any((PersonProfile p) => p.id == _selectedPersonId) + ? _selectedPersonId + : fallbackPersonId; + + return Padding( + padding: const EdgeInsets.fromLTRB(28, 24, 28, 28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Moments', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 6), + Text( + 'Capture wins and signals from everyday interactions.', + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 16), + FrostedCard( + child: Column( + children: [ + Row( + children: [ + Expanded( + child: TextField( + controller: _captureController, + decoration: InputDecoration( + hintText: + 'Quick capture: what happened, how it felt, what to repeat...', + hintStyle: Theme.of(context).textTheme.bodyMedium ?.copyWith(color: AppTheme.textSecondary), + border: InputBorder.none, ), - const SizedBox(height: 8), - Text( - moment.summary, - style: Theme.of(context).textTheme.bodyLarge, - ), - ], + ), + ), + const SizedBox(width: 8), + if (people.isNotEmpty) + DropdownButton( + value: activePerson, + hint: const Text('Person'), + items: people + .map( + (PersonProfile person) => + DropdownMenuItem( + value: person.id, + child: Text(person.name), + ), + ) + .toList(growable: false), + onChanged: (String? value) { + setState(() { + _selectedPersonId = value; + }); + }, + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: people.isEmpty || activePerson == null + ? null + : () => _addCapture(activePerson), + icon: const Icon(Icons.add_rounded), + label: const Text('Add Capture'), + ), + ], + ), + if (people.isEmpty) + Padding( + padding: const EdgeInsets.only(top: 10), + child: Text( + 'Add people first before capturing moments.', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: AppTheme.textSecondary), ), ), - const SizedBox(width: 12), - Text( - '${moment.at.month}/${moment.at.day}', - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: AppTheme.textSecondary, - ), + ], + ), + ), + const SizedBox(height: 16), + Expanded( + child: ListView.separated( + itemCount: moments.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (BuildContext context, int index) { + final RelationshipMoment moment = moments[index]; + final PersonProfile? person = peopleById[moment.personId]; + return FrostedCard( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 12, + height: 12, + margin: const EdgeInsets.only(top: 6), + decoration: const BoxDecoration( + color: Color(0xFF1AB6C8), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + moment.title, + style: Theme.of( + context, + ).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + '${person?.name ?? 'Unknown'} • ${moment.type.toUpperCase()}', + style: Theme.of(context).textTheme.labelLarge + ?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 8), + Text( + moment.summary, + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${moment.at.month}/${moment.at.day}', + style: Theme.of(context).textTheme.labelLarge + ?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 8), + IconButton( + onPressed: () { + ref + .read(localRepositoryProvider.notifier) + .deleteMoment(moment.id); + }, + icon: const Icon(Icons.delete_outline_rounded), + tooltip: 'Delete capture', + ), + ], + ), + ], ), - ], - ), - ); - }, - ), + ); + }, + ), + ), + ], ), - ], - ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (Object error, StackTrace stackTrace) { + return const Center(child: Text('Unable to load moments')); + }, ); } + + Future _addCapture(String personId) async { + final String summary = _captureController.text.trim(); + if (summary.isEmpty) { + return; + } + + await ref + .read(localRepositoryProvider.notifier) + .addMoment(personId: personId, summary: summary); + _captureController.clear(); + } } diff --git a/lib/features/people/people_view.dart b/lib/features/people/people_view.dart index f209fc1..608e55d 100644 --- a/lib/features/people/people_view.dart +++ b/lib/features/people/people_view.dart @@ -12,6 +12,10 @@ class SelectedPersonIdNotifier extends Notifier { void select(String id) { state = id; } + + void clear() { + state = null; + } } final NotifierProvider @@ -24,114 +28,286 @@ class PeopleView extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final List people = ref - .watch(localRepositoryProvider) - .people(); - final String selectedId = - ref.watch(selectedPersonIdProvider) ?? people.first.id; - final PersonProfile selected = people.firstWhere( - (PersonProfile person) => person.id == selectedId, - orElse: () => people.first, + final AsyncValue localData = ref.watch( + localRepositoryProvider, ); - return Padding( - padding: const EdgeInsets.fromLTRB(28, 24, 28, 28), - child: Row( - children: [ - Expanded( - flex: 5, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'People', - style: Theme.of(context).textTheme.headlineMedium, - ), - const SizedBox(height: 6), - Text( - 'Track what matters to each relationship and follow through.', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: AppTheme.textSecondary, - ), - ), - const SizedBox(height: 20), - Expanded( - child: ListView.separated( - itemCount: people.length, - separatorBuilder: (_, _) => const SizedBox(height: 12), - itemBuilder: (BuildContext context, int index) { - final PersonProfile person = people[index]; - final bool isSelected = person.id == selected.id; - return InkWell( - onTap: () { - ref - .read(selectedPersonIdProvider.notifier) - .select(person.id); - }, - borderRadius: BorderRadius.circular(20), - child: FrostedCard( - padding: const EdgeInsets.all(16), - child: Row( + return localData.when( + data: (LocalDataState data) { + final List people = data.people; + if (people.isEmpty) { + return _EmptyPeopleView(onAdd: () => _handleAddPerson(context, ref)); + } + + final String selectedId = + ref.watch(selectedPersonIdProvider) ?? people.first.id; + final PersonProfile selected = people.firstWhere( + (PersonProfile person) => person.id == selectedId, + orElse: () => people.first, + ); + + return Padding( + padding: const EdgeInsets.fromLTRB(28, 24, 28, 28), + child: Row( + children: [ + Expanded( + flex: 5, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _AvatarSeed(name: person.name), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - person.name, - style: Theme.of( - context, - ).textTheme.titleMedium, - ), - const SizedBox(height: 4), - Text( - person.relationship, - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith( - color: AppTheme.textSecondary, - ), - ), - ], - ), + Text( + 'People', + style: Theme.of( + context, + ).textTheme.headlineMedium, + ), + const SizedBox(height: 6), + Text( + 'Track what matters to each relationship and follow through.', + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith(color: AppTheme.textSecondary), ), - _ScorePill(score: person.affinityScore), - if (isSelected) - const Padding( - padding: EdgeInsets.only(left: 10), - child: Icon( - Icons.check_circle, - color: Color(0xFF1AB6C8), - ), - ), ], ), ), - ); - }, + FilledButton.icon( + onPressed: () => _handleAddPerson(context, ref), + icon: const Icon(Icons.person_add_alt_1_rounded), + label: const Text('Add'), + ), + ], + ), + const SizedBox(height: 20), + Expanded( + child: ListView.separated( + itemCount: people.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (BuildContext context, int index) { + final PersonProfile person = people[index]; + final bool isSelected = person.id == selected.id; + return InkWell( + onTap: () { + ref + .read(selectedPersonIdProvider.notifier) + .select(person.id); + }, + borderRadius: BorderRadius.circular(20), + child: FrostedCard( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + _AvatarSeed(name: person.name), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + person.name, + style: Theme.of( + context, + ).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + person.relationship, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + _ScorePill(score: person.affinityScore), + if (isSelected) + const Padding( + padding: EdgeInsets.only(left: 10), + child: Icon( + Icons.check_circle, + color: Color(0xFF1AB6C8), + ), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ), + const SizedBox(width: 18), + Expanded( + flex: 6, + child: FrostedCard( + child: _PersonDetail( + person: selected, + onEdit: () => _handleEditPerson(context, ref, selected), + onDelete: () => + _handleDeletePerson(context, ref, selected.id), ), ), - ], + ), + ], + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (Object error, StackTrace stackTrace) { + return const Center(child: Text('Unable to load people')); + }, + ); + } + + Future _handleAddPerson(BuildContext context, WidgetRef ref) async { + final _PersonDraft? draft = await showDialog<_PersonDraft>( + context: context, + builder: (BuildContext context) => + const _PersonEditorDialog(title: 'Add Person'), + ); + + if (draft == null) { + return; + } + + await ref + .read(localRepositoryProvider.notifier) + .addPerson( + name: draft.name, + relationship: draft.relationship, + notes: draft.notes, + tags: draft.tags, + ); + } + + Future _handleEditPerson( + BuildContext context, + WidgetRef ref, + PersonProfile person, + ) async { + final _PersonDraft? draft = await showDialog<_PersonDraft>( + context: context, + builder: (BuildContext context) => _PersonEditorDialog( + title: 'Edit Person', + initial: _PersonDraft( + name: person.name, + relationship: person.relationship, + notes: person.notes, + tags: person.tags, + ), + ), + ); + + if (draft == null) { + return; + } + + await ref + .read(localRepositoryProvider.notifier) + .updatePerson( + person.copyWith( + name: draft.name, + relationship: draft.relationship, + notes: draft.notes, + tags: draft.tags, + ), + ); + } + + Future _handleDeletePerson( + BuildContext context, + WidgetRef ref, + String personId, + ) async { + final bool? confirmed = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Delete person?'), + content: const Text( + 'This will remove the person and related moments from local storage.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), ), - ), - const SizedBox(width: 18), - Expanded( - flex: 6, - child: FrostedCard(child: _PersonDetail(person: selected)), - ), - ], + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Delete'), + ), + ], + ); + }, + ); + + if (confirmed != true) { + return; + } + + await ref.read(localRepositoryProvider.notifier).deletePerson(personId); + ref.read(selectedPersonIdProvider.notifier).clear(); + } +} + +class _EmptyPeopleView extends StatelessWidget { + const _EmptyPeopleView({required this.onAdd}); + + final VoidCallback onAdd; + + @override + Widget build(BuildContext context) { + return Center( + child: FrostedCard( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'No people yet', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + 'Add the first relationship profile to start tracking moments.', + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: onAdd, + icon: const Icon(Icons.person_add_alt_1_rounded), + label: const Text('Add Person'), + ), + ], + ), ), ); } } class _PersonDetail extends StatelessWidget { - const _PersonDetail({required this.person}); + const _PersonDetail({ + required this.person, + required this.onEdit, + required this.onDelete, + }); final PersonProfile person; + final VoidCallback onEdit; + final VoidCallback onDelete; @override Widget build(BuildContext context) { @@ -160,7 +336,16 @@ class _PersonDetail extends StatelessWidget { ], ), ), - _ScorePill(score: person.affinityScore), + IconButton( + onPressed: onEdit, + icon: const Icon(Icons.edit_rounded), + tooltip: 'Edit person', + ), + IconButton( + onPressed: onDelete, + icon: const Icon(Icons.delete_outline_rounded), + tooltip: 'Delete person', + ), ], ), const SizedBox(height: 22), @@ -178,7 +363,7 @@ class _PersonDetail extends StatelessWidget { runSpacing: 8, children: person.tags .map((String tag) => _DetailTag(label: tag)) - .toList(), + .toList(growable: false), ), const SizedBox(height: 18), Text('Notes', style: Theme.of(context).textTheme.titleMedium), @@ -194,6 +379,123 @@ class _PersonDetail extends StatelessWidget { } } +class _PersonEditorDialog extends StatefulWidget { + const _PersonEditorDialog({required this.title, this.initial}); + + final String title; + final _PersonDraft? initial; + + @override + State<_PersonEditorDialog> createState() => _PersonEditorDialogState(); +} + +class _PersonEditorDialogState extends State<_PersonEditorDialog> { + late final TextEditingController _nameController; + late final TextEditingController _relationshipController; + late final TextEditingController _tagsController; + late final TextEditingController _notesController; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(text: widget.initial?.name ?? ''); + _relationshipController = TextEditingController( + text: widget.initial?.relationship ?? '', + ); + _tagsController = TextEditingController( + text: widget.initial?.tags.join(', ') ?? '', + ); + _notesController = TextEditingController(text: widget.initial?.notes ?? ''); + } + + @override + void dispose() { + _nameController.dispose(); + _relationshipController.dispose(); + _tagsController.dispose(); + _notesController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.title), + content: SingleChildScrollView( + child: SizedBox( + width: 420, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _nameController, + decoration: const InputDecoration(labelText: 'Name'), + ), + TextField( + controller: _relationshipController, + decoration: const InputDecoration(labelText: 'Relationship'), + ), + TextField( + controller: _tagsController, + decoration: const InputDecoration( + labelText: 'Tags (comma separated)', + ), + ), + TextField( + controller: _notesController, + maxLines: 3, + decoration: const InputDecoration(labelText: 'Notes'), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final String name = _nameController.text.trim(); + final String relationship = _relationshipController.text.trim(); + if (name.isEmpty || relationship.isEmpty) { + return; + } + + final _PersonDraft draft = _PersonDraft( + name: name, + relationship: relationship, + notes: _notesController.text.trim(), + tags: _tagsController.text + .split(',') + .map((String tag) => tag.trim()) + .where((String tag) => tag.isNotEmpty) + .toList(growable: false), + ); + Navigator.of(context).pop(draft); + }, + child: const Text('Save'), + ), + ], + ); + } +} + +class _PersonDraft { + const _PersonDraft({ + required this.name, + required this.relationship, + required this.notes, + required this.tags, + }); + + final String name; + final String relationship; + final String notes; + final List tags; +} + class _DetailTag extends StatelessWidget { const _DetailTag({required this.label}); diff --git a/lib/features/signals/signals_controller.dart b/lib/features/signals/signals_controller.dart index a56467e..12d0a43 100644 --- a/lib/features/signals/signals_controller.dart +++ b/lib/features/signals/signals_controller.dart @@ -40,9 +40,24 @@ class SignalsController extends AsyncNotifier { try { return await ref.read(backendGatewayProvider).getSignalsFeed(limit: 20); } catch (_) { - final List people = ref - .read(localRepositoryProvider) - .people(); + final LocalDataState localData = await ref.read( + localRepositoryProvider.future, + ); + final List people = localData.people; + final PersonProfile defaultPerson = people.isNotEmpty + ? people.first + : PersonProfile( + id: 'person-default', + name: 'Someone Important', + relationship: 'Relationship', + affinityScore: 70, + nextMoment: DateTime(2026, 2, 20), + tags: [], + notes: '', + ); + final PersonProfile secondaryPerson = people.length > 1 + ? people[1] + : defaultPerson; final DateTime now = DateTime.now().toUtc(); return SignalsFeed( cursor: 'fallback-signals', @@ -50,9 +65,9 @@ class SignalsController extends AsyncNotifier { SignalItem( id: 'fallback-1', type: 'recommendation', - title: 'Check in with ${people.first.name} tonight', + title: 'Check in with ${defaultPerson.name} tonight', description: 'A short voice memo can keep momentum.', - personId: people.first.id, + personId: defaultPerson.id, createdAt: now, ), SignalItem( @@ -60,7 +75,7 @@ class SignalsController extends AsyncNotifier { type: 'trend', title: 'Gift idea: practical + personal is trending', description: 'Combine utility with one sentimental detail.', - personId: people.last.id, + personId: secondaryPerson.id, createdAt: now.subtract(const Duration(hours: 2)), ), ], diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 67d652c..ee71999 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,7 +6,9 @@ import FlutterMacOS import Foundation import flutter_secure_storage_darwin +import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index f6ebf0a..9efeae3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -648,6 +648,62 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.1" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f + url: "https://pub.dev" + source: hosted + version: "2.4.20" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" shelf: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index df84d89..ba5a1f7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,6 +41,7 @@ dependencies: uuid: ^4.5.2 clock: ^1.1.2 flutter_riverpod: ^3.2.1 + shared_preferences: ^2.5.4 dev_dependencies: flutter_test: diff --git a/test/features/local/local_repository_test.dart b/test/features/local/local_repository_test.dart new file mode 100644 index 0000000..9a3193c --- /dev/null +++ b/test/features/local/local_repository_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:relationship_saver/features/local/local_repository.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('seeds local data on first load', () async { + final ProviderContainer container = ProviderContainer(); + addTearDown(container.dispose); + + final state = await container.read(localRepositoryProvider.future); + + expect(state.people, isNotEmpty); + expect(state.moments, isNotEmpty); + expect(state.tasks, isNotEmpty); + }); + + test('adds and removes person with persistence state update', () async { + final ProviderContainer container = ProviderContainer(); + addTearDown(container.dispose); + + final initial = await container.read(localRepositoryProvider.future); + final int initialCount = initial.people.length; + + await container + .read(localRepositoryProvider.notifier) + .addPerson( + name: 'Test Person', + relationship: 'Friend', + notes: 'test', + tags: ['alpha'], + ); + + final afterAdd = await container.read(localRepositoryProvider.future); + expect(afterAdd.people.length, initialCount + 1); + + final String insertedId = afterAdd.people.first.id; + await container + .read(localRepositoryProvider.notifier) + .deletePerson(insertedId); + + final afterDelete = await container.read(localRepositoryProvider.future); + expect(afterDelete.people.length, initialCount); + }); + + test('adds and removes capture moments', () async { + final ProviderContainer container = ProviderContainer(); + addTearDown(container.dispose); + + final state = await container.read(localRepositoryProvider.future); + final String personId = state.people.first.id; + final int beforeCount = state.moments.length; + + await container + .read(localRepositoryProvider.notifier) + .addMoment(personId: personId, summary: 'This is a new capture moment'); + + final withMoment = await container.read(localRepositoryProvider.future); + expect(withMoment.moments.length, beforeCount + 1); + + final String momentId = withMoment.moments.first.id; + await container + .read(localRepositoryProvider.notifier) + .deleteMoment(momentId); + + final afterDelete = await container.read(localRepositoryProvider.future); + expect(afterDelete.moments.length, beforeCount); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 4788b72..e47e0ce 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,8 +1,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:relationship_saver/main.dart'; +import 'package:shared_preferences/shared_preferences.dart'; void main() { + setUpAll(() async { + SharedPreferences.setMockInitialValues({}); + }); + testWidgets('renders dashboard flow', (WidgetTester tester) async { await tester.pumpWidget(const ProviderScope(child: RelationshipSaverApp())); await tester.pumpAndSettle();