From a33e663d570a5ccc76523bfac406bb057af7f784 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Sun, 15 Feb 2026 15:20:23 +0100 Subject: [PATCH] Add ideas and reminders CRUD flows with navigation --- docs/progress.md | 25 ++ lib/features/home/app_shell.dart | 30 +- lib/features/ideas/ideas_view.dart | 395 +++++++++++++++++ lib/features/local/local_models.dart | 217 +++++++++- lib/features/local/local_repository.dart | 172 +++++++- lib/features/reminders/reminders_view.dart | 400 ++++++++++++++++++ .../features/local/local_repository_test.dart | 101 +++++ 7 files changed, 1316 insertions(+), 24 deletions(-) create mode 100644 lib/features/ideas/ideas_view.dart create mode 100644 lib/features/reminders/reminders_view.dart diff --git a/docs/progress.md b/docs/progress.md index 7dbe9d3..d13b72f 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -2,6 +2,31 @@ Updated: 2026-02-15 +## Latest Milestone (2026-02-15): Ideas + Reminders Phase + +Implemented the next Phase 2 slice by introducing Ideas and Reminder Rules as first-class local features. + +- Expanded local domain/state: + - `IdeaType`, `ReminderCadence`, `RelationshipIdea`, `ReminderRule` + - `LocalDataState` now persists `ideas` and `reminders` +- Expanded local repository CRUD: + - ideas: add/update/archive/delete + - reminders: add/update/toggle/delete + - people deletion now cascades to moments/ideas/reminders +- Added new feature views: + - `lib/features/ideas/ideas_view.dart` + - `lib/features/reminders/reminders_view.dart` +- Updated shell navigation: + - desktop sidebar now includes `Ideas` and `Reminders` + - mobile keeps compact primary tabs and exposes `Ideas`/`Reminders` under `More` +- Expanded local repository tests for new CRUD surface: + - `test/features/local/local_repository_test.dart` + +Validation for this milestone: + +- `flutter analyze` -> pass +- `flutter test` -> pass + ## Latest Milestone (2026-02-15): Local Persistence + CRUD Baseline Implemented the first real local data foundation pass (replacing static demo-only reads): diff --git a/lib/features/home/app_shell.dart b/lib/features/home/app_shell.dart index 128fc24..6b02b17 100644 --- a/lib/features/home/app_shell.dart +++ b/lib/features/home/app_shell.dart @@ -2,8 +2,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:relationship_saver/core/config/app_theme.dart'; import 'package:relationship_saver/features/dashboard/dashboard_view.dart'; +import 'package:relationship_saver/features/ideas/ideas_view.dart'; import 'package:relationship_saver/features/moments/moments_view.dart'; import 'package:relationship_saver/features/people/people_view.dart'; +import 'package:relationship_saver/features/reminders/reminders_view.dart'; import 'package:relationship_saver/features/settings/settings_view.dart'; import 'package:relationship_saver/features/signals/signals_view.dart'; import 'package:relationship_saver/features/sync/sync_view.dart'; @@ -23,8 +25,10 @@ class _AppShellState extends ConsumerState { _ShellDestination('People', Icons.people_alt_rounded, 1), _ShellDestination('Moments', Icons.auto_awesome_rounded, 2), _ShellDestination('Signals', Icons.tips_and_updates_rounded, 3), - _ShellDestination('Sync', Icons.sync_rounded, 4), - _ShellDestination('Settings', Icons.settings_rounded, 5), + _ShellDestination('Ideas', Icons.lightbulb_outline_rounded, 4), + _ShellDestination('Reminders', Icons.notifications_active_outlined, 5), + _ShellDestination('Sync', Icons.sync_rounded, 6), + _ShellDestination('Settings', Icons.settings_rounded, 7), ]; static const List<_ShellDestination> _compactPrimaryDestinations = @@ -70,8 +74,10 @@ class _AppShellState extends ConsumerState { }, onOpenSecondary: (_SecondaryDestination destination) { final int screenIndex = switch (destination) { - _SecondaryDestination.sync => 4, - _SecondaryDestination.settings => 5, + _SecondaryDestination.ideas => 4, + _SecondaryDestination.reminders => 5, + _SecondaryDestination.sync => 6, + _SecondaryDestination.settings => 7, }; Navigator.of(context).push( @@ -137,8 +143,12 @@ class _AppShellState extends ConsumerState { case 3: return const SignalsView(); case 4: - return const SyncView(); + return const IdeasView(); case 5: + return const RemindersView(); + case 6: + return const SyncView(); + case 7: return const SettingsView(); default: return const DashboardView(); @@ -298,6 +308,14 @@ class _CompactShell extends StatelessWidget { onSelected: onOpenSecondary, itemBuilder: (BuildContext context) => >[ + const PopupMenuItem<_SecondaryDestination>( + value: _SecondaryDestination.ideas, + child: Text('Ideas'), + ), + const PopupMenuItem<_SecondaryDestination>( + value: _SecondaryDestination.reminders, + child: Text('Reminders'), + ), const PopupMenuItem<_SecondaryDestination>( value: _SecondaryDestination.sync, child: Text('Sync'), @@ -359,7 +377,7 @@ class _CompactSecondaryScreen extends StatelessWidget { } } -enum _SecondaryDestination { sync, settings } +enum _SecondaryDestination { ideas, reminders, sync, settings } class _ShellDestination { const _ShellDestination(this.label, this.icon, this.screenIndex); diff --git a/lib/features/ideas/ideas_view.dart b/lib/features/ideas/ideas_view.dart new file mode 100644 index 0000000..6f455b0 --- /dev/null +++ b/lib/features/ideas/ideas_view.dart @@ -0,0 +1,395 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/config/app_theme.dart'; +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 IdeasView extends ConsumerWidget { + const IdeasView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final AsyncValue localData = ref.watch( + localRepositoryProvider, + ); + + return localData.when( + data: (LocalDataState data) { + final List ideas = data.ideas; + final Map peopleById = { + for (final PersonProfile person in data.people) person.id: person, + }; + + return Padding( + padding: const EdgeInsets.fromLTRB(28, 24, 28, 28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Ideas', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 6), + Text( + 'Capture gift and event ideas before they slip away.', + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith(color: AppTheme.textSecondary), + ), + ], + ), + ), + FilledButton.icon( + onPressed: () => _addIdea(context, ref, data.people), + icon: const Icon(Icons.lightbulb_outline_rounded), + label: const Text('Add Idea'), + ), + ], + ), + const SizedBox(height: 16), + Expanded( + child: ListView.separated( + itemCount: ideas.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (BuildContext context, int index) { + final RelationshipIdea idea = ideas[index]; + final PersonProfile? person = idea.personId == null + ? null + : peopleById[idea.personId!]; + return FrostedCard( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _IdeaTypeTag(type: idea.type), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + idea.title, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith( + decoration: idea.isArchived + ? TextDecoration.lineThrough + : null, + ), + ), + if (idea.details.trim().isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + idea.details, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ), + const SizedBox(height: 8), + Text( + person == null ? 'Unassigned' : person.name, + style: Theme.of(context).textTheme.labelLarge + ?.copyWith(color: AppTheme.textSecondary), + ), + ], + ), + ), + const SizedBox(width: 8), + Column( + children: [ + IconButton( + onPressed: () { + ref + .read(localRepositoryProvider.notifier) + .toggleIdeaArchived(idea.id); + }, + icon: Icon( + idea.isArchived + ? Icons.unarchive_outlined + : Icons.archive_outlined, + ), + tooltip: idea.isArchived + ? 'Unarchive' + : 'Archive', + ), + IconButton( + onPressed: () => + _editIdea(context, ref, data.people, idea), + icon: const Icon(Icons.edit_rounded), + tooltip: 'Edit', + ), + IconButton( + onPressed: () { + ref + .read(localRepositoryProvider.notifier) + .deleteIdea(idea.id); + }, + icon: const Icon(Icons.delete_outline_rounded), + tooltip: 'Delete', + ), + ], + ), + ], + ), + ); + }, + ), + ), + ], + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (Object error, StackTrace stackTrace) { + return const Center(child: Text('Unable to load ideas')); + }, + ); + } + + Future _addIdea( + BuildContext context, + WidgetRef ref, + List people, + ) async { + final _IdeaDraft? draft = await showDialog<_IdeaDraft>( + context: context, + builder: (BuildContext context) => + _IdeaEditorDialog(title: 'Add Idea', people: people), + ); + + if (draft == null) { + return; + } + + await ref + .read(localRepositoryProvider.notifier) + .addIdea( + type: draft.type, + title: draft.title, + details: draft.details, + personId: draft.personId, + ); + } + + Future _editIdea( + BuildContext context, + WidgetRef ref, + List people, + RelationshipIdea idea, + ) async { + final _IdeaDraft? draft = await showDialog<_IdeaDraft>( + context: context, + builder: (BuildContext context) => _IdeaEditorDialog( + title: 'Edit Idea', + people: people, + initial: _IdeaDraft( + personId: idea.personId, + type: idea.type, + title: idea.title, + details: idea.details, + ), + ), + ); + + if (draft == null) { + return; + } + + await ref + .read(localRepositoryProvider.notifier) + .updateIdea( + idea.copyWith( + personId: draft.personId, + type: draft.type, + title: draft.title, + details: draft.details, + ), + ); + } +} + +class _IdeaTypeTag extends StatelessWidget { + const _IdeaTypeTag({required this.type}); + + final IdeaType type; + + @override + Widget build(BuildContext context) { + final (Color bg, Color fg, String label) = switch (type) { + IdeaType.gift => (const Color(0xFFEAF7FB), AppTheme.primary, 'GIFT'), + IdeaType.event => ( + const Color(0xFFEEF7F3), + const Color(0xFF1D9C66), + 'EVENT', + ), + }; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(99), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: fg, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +class _IdeaEditorDialog extends StatefulWidget { + const _IdeaEditorDialog({ + required this.title, + required this.people, + this.initial, + }); + + final String title; + final List people; + final _IdeaDraft? initial; + + @override + State<_IdeaEditorDialog> createState() => _IdeaEditorDialogState(); +} + +class _IdeaEditorDialogState extends State<_IdeaEditorDialog> { + late final TextEditingController _titleController; + late final TextEditingController _detailsController; + late IdeaType _type; + String? _personId; + + @override + void initState() { + super.initState(); + _titleController = TextEditingController(text: widget.initial?.title ?? ''); + _detailsController = TextEditingController( + text: widget.initial?.details ?? '', + ); + _type = widget.initial?.type ?? IdeaType.gift; + _personId = widget.initial?.personId; + } + + @override + void dispose() { + _titleController.dispose(); + _detailsController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.title), + content: SizedBox( + width: 430, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownButtonFormField( + initialValue: _type, + items: IdeaType.values + .map( + (IdeaType type) => DropdownMenuItem( + value: type, + child: Text(type.name.toUpperCase()), + ), + ) + .toList(growable: false), + onChanged: (IdeaType? value) { + if (value == null) { + return; + } + setState(() { + _type = value; + }); + }, + decoration: const InputDecoration(labelText: 'Type'), + ), + DropdownButtonFormField( + initialValue: _personId, + items: >[ + const DropdownMenuItem( + value: null, + child: Text('Unassigned'), + ), + ...widget.people.map( + (PersonProfile person) => DropdownMenuItem( + value: person.id, + child: Text(person.name), + ), + ), + ], + onChanged: (String? value) { + setState(() { + _personId = value; + }); + }, + decoration: const InputDecoration(labelText: 'Person'), + ), + TextField( + controller: _titleController, + decoration: const InputDecoration(labelText: 'Title'), + ), + TextField( + controller: _detailsController, + maxLines: 3, + decoration: const InputDecoration(labelText: 'Details'), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final String title = _titleController.text.trim(); + if (title.isEmpty) { + return; + } + Navigator.of(context).pop( + _IdeaDraft( + personId: _personId, + type: _type, + title: title, + details: _detailsController.text.trim(), + ), + ); + }, + child: const Text('Save'), + ), + ], + ); + } +} + +class _IdeaDraft { + const _IdeaDraft({ + required this.personId, + required this.type, + required this.title, + required this.details, + }); + + final String? personId; + final IdeaType type; + final String title; + final String details; +} diff --git a/lib/features/local/local_models.dart b/lib/features/local/local_models.dart index f2e725b..1bf6ef2 100644 --- a/lib/features/local/local_models.dart +++ b/lib/features/local/local_models.dart @@ -2,6 +2,10 @@ import 'package:flutter/foundation.dart'; +enum IdeaType { gift, event } + +enum ReminderCadence { daily, weekly, monthly } + @immutable class PersonProfile { const PersonProfile({ @@ -61,10 +65,10 @@ class PersonProfile { relationship: json['relationship'] as String, affinityScore: (json['affinityScore'] as num).toInt(), nextMoment: DateTime.parse(json['nextMoment'] as String).toLocal(), - tags: (json['tags'] as List) + tags: (json['tags'] as List? ?? []) .map((dynamic tag) => '$tag') - .toList(), - notes: json['notes'] as String, + .toList(growable: false), + notes: json['notes'] as String? ?? '', ); } } @@ -128,6 +132,139 @@ class RelationshipMoment { } } +@immutable +class RelationshipIdea { + const RelationshipIdea({ + required this.id, + required this.type, + required this.title, + required this.details, + required this.createdAt, + this.personId, + this.isArchived = false, + }); + + final String id; + final String? personId; + final IdeaType type; + final String title; + final String details; + final DateTime createdAt; + final bool isArchived; + + RelationshipIdea copyWith({ + String? id, + String? personId, + IdeaType? type, + String? title, + String? details, + DateTime? createdAt, + bool? isArchived, + }) { + return RelationshipIdea( + id: id ?? this.id, + personId: personId ?? this.personId, + type: type ?? this.type, + title: title ?? this.title, + details: details ?? this.details, + createdAt: createdAt ?? this.createdAt, + isArchived: isArchived ?? this.isArchived, + ); + } + + Map toJson() { + return { + 'id': id, + 'personId': personId, + 'type': type.name, + 'title': title, + 'details': details, + 'createdAt': createdAt.toUtc().toIso8601String(), + 'isArchived': isArchived, + }; + } + + factory RelationshipIdea.fromJson(Map json) { + final String typeName = json['type'] as String? ?? IdeaType.gift.name; + return RelationshipIdea( + id: json['id'] as String, + personId: json['personId'] as String?, + type: IdeaType.values.firstWhere( + (IdeaType type) => type.name == typeName, + orElse: () => IdeaType.gift, + ), + title: json['title'] as String, + details: json['details'] as String? ?? '', + createdAt: DateTime.parse(json['createdAt'] as String).toLocal(), + isArchived: json['isArchived'] as bool? ?? false, + ); + } +} + +@immutable +class ReminderRule { + const ReminderRule({ + required this.id, + required this.title, + required this.cadence, + required this.nextAt, + this.personId, + this.enabled = true, + }); + + final String id; + final String? personId; + final String title; + final ReminderCadence cadence; + final DateTime nextAt; + final bool enabled; + + ReminderRule copyWith({ + String? id, + String? personId, + String? title, + ReminderCadence? cadence, + DateTime? nextAt, + bool? enabled, + }) { + return ReminderRule( + id: id ?? this.id, + personId: personId ?? this.personId, + title: title ?? this.title, + cadence: cadence ?? this.cadence, + nextAt: nextAt ?? this.nextAt, + enabled: enabled ?? this.enabled, + ); + } + + Map toJson() { + return { + 'id': id, + 'personId': personId, + 'title': title, + 'cadence': cadence.name, + 'nextAt': nextAt.toUtc().toIso8601String(), + 'enabled': enabled, + }; + } + + factory ReminderRule.fromJson(Map json) { + final String cadenceName = + json['cadence'] as String? ?? ReminderCadence.weekly.name; + return ReminderRule( + id: json['id'] as String, + personId: json['personId'] as String?, + title: json['title'] as String, + cadence: ReminderCadence.values.firstWhere( + (ReminderCadence cadence) => cadence.name == cadenceName, + orElse: () => ReminderCadence.weekly, + ), + nextAt: DateTime.parse(json['nextAt'] as String).toLocal(), + enabled: json['enabled'] as bool? ?? true, + ); + } +} + @immutable class DashboardTask { const DashboardTask({ @@ -201,21 +338,29 @@ class LocalDataState { const LocalDataState({ required this.people, required this.moments, + required this.ideas, + required this.reminders, required this.tasks, }); final List people; final List moments; + final List ideas; + final List reminders; final List tasks; LocalDataState copyWith({ List? people, List? moments, + List? ideas, + List? reminders, List? tasks, }) { return LocalDataState( people: people ?? this.people, moments: moments ?? this.moments, + ideas: ideas ?? this.ideas, + reminders: reminders ?? this.reminders, tasks: tasks ?? this.tasks, ); } @@ -227,11 +372,9 @@ class LocalDataState { upcomingPlans: people .where((PersonProfile p) => p.nextMoment.isAfter(baseline)) .length, - pendingIdeas: people.fold( - 0, - (int acc, PersonProfile person) => - acc + (person.tags.isNotEmpty ? 1 : 0), - ), + pendingIdeas: ideas + .where((RelationshipIdea idea) => !idea.isArchived) + .length, weeklyConsistency: moments.isEmpty ? 0 : (70 + moments.length * 4).clamp(0, 100), @@ -246,6 +389,12 @@ class LocalDataState { 'moments': moments .map((RelationshipMoment moment) => moment.toJson()) .toList(growable: false), + 'ideas': ideas + .map((RelationshipIdea idea) => idea.toJson()) + .toList(growable: false), + 'reminders': reminders + .map((ReminderRule reminder) => reminder.toJson()) + .toList(growable: false), 'tasks': tasks .map((DashboardTask task) => task.toJson()) .toList(growable: false), @@ -254,19 +403,31 @@ class LocalDataState { factory LocalDataState.fromJson(Map json) { return LocalDataState( - people: (json['people'] as List) + people: (json['people'] as List? ?? []) .map( (dynamic person) => PersonProfile.fromJson(person as Map), ) .toList(growable: false), - moments: (json['moments'] as List) + moments: (json['moments'] as List? ?? []) .map( (dynamic moment) => RelationshipMoment.fromJson(moment as Map), ) .toList(growable: false), - tasks: (json['tasks'] as List) + ideas: (json['ideas'] as List? ?? []) + .map( + (dynamic idea) => + RelationshipIdea.fromJson(idea as Map), + ) + .toList(growable: false), + reminders: (json['reminders'] as List? ?? []) + .map( + (dynamic reminder) => + ReminderRule.fromJson(reminder as Map), + ) + .toList(growable: false), + tasks: (json['tasks'] as List? ?? []) .map( (dynamic task) => DashboardTask.fromJson(task as Map), @@ -332,6 +493,40 @@ class LocalDataState { type: 'gift', ), ], + ideas: [ + RelationshipIdea( + id: 'i-1', + personId: 'p-ava', + type: IdeaType.gift, + title: 'Handwritten recipe journal', + details: 'Collect 10 memories and recipes from this year.', + createdAt: DateTime(2026, 2, 12, 9), + ), + RelationshipIdea( + id: 'i-2', + personId: 'p-mila', + type: IdeaType.event, + title: 'Plant market + brunch date', + details: 'Saturday morning slot; book nearby cafe in advance.', + createdAt: DateTime(2026, 2, 13, 11), + ), + ], + reminders: [ + ReminderRule( + id: 'r-1', + personId: 'p-jordan', + title: 'Weekly check-in message', + cadence: ReminderCadence.weekly, + nextAt: DateTime(2026, 2, 18, 18), + ), + ReminderRule( + id: 'r-2', + personId: 'p-ava', + title: 'Sunday ritual planning', + cadence: ReminderCadence.weekly, + nextAt: DateTime(2026, 2, 16, 10), + ), + ], tasks: [ DashboardTask( id: 't-1', diff --git a/lib/features/local/local_repository.dart b/lib/features/local/local_repository.dart index be7d889..298e397 100644 --- a/lib/features/local/local_repository.dart +++ b/lib/features/local/local_repository.dart @@ -9,7 +9,7 @@ import 'package:uuid/uuid.dart'; 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; + static const int _schemaVersion = 2; static const Uuid _uuid = Uuid(); @@ -42,11 +42,17 @@ class LocalRepository extends AsyncNotifier { required List tags, DateTime? nextMoment, }) async { + final String normalizedName = name.trim(); + final String normalizedRelationship = relationship.trim(); + if (normalizedName.isEmpty || normalizedRelationship.isEmpty) { + throw ArgumentError('Name and relationship are required'); + } + final LocalDataState current = _requireState(); final PersonProfile person = PersonProfile( id: 'p-${_uuid.v4()}', - name: name.trim(), - relationship: relationship.trim(), + name: normalizedName, + relationship: normalizedRelationship, affinityScore: 75, nextMoment: nextMoment ?? DateTime.now().add(const Duration(days: 3)), tags: tags, @@ -61,6 +67,10 @@ class LocalRepository extends AsyncNotifier { } Future updatePerson(PersonProfile person) async { + if (person.name.trim().isEmpty || person.relationship.trim().isEmpty) { + throw ArgumentError('Name and relationship are required'); + } + final LocalDataState current = _requireState(); final List updated = current.people .map((PersonProfile item) => item.id == person.id ? person : item) @@ -76,8 +86,21 @@ class LocalRepository extends AsyncNotifier { final List moments = current.moments .where((RelationshipMoment moment) => moment.personId != personId) .toList(growable: false); + final List ideas = current.ideas + .where((RelationshipIdea idea) => idea.personId != personId) + .toList(growable: false); + final List reminders = current.reminders + .where((ReminderRule reminder) => reminder.personId != personId) + .toList(growable: false); - await _setState(current.copyWith(people: people, moments: moments)); + await _setState( + current.copyWith( + people: people, + moments: moments, + ideas: ideas, + reminders: reminders, + ), + ); } Future addMoment({ @@ -88,15 +111,18 @@ class LocalRepository extends AsyncNotifier { final LocalDataState current = _requireState(); final String normalized = summary.trim(); if (normalized.isEmpty) { - return; + throw ArgumentError('Capture summary cannot be empty'); } - final String title = _titleFromSummary(normalized); + final String payload = normalized.length > 280 + ? normalized.substring(0, 280) + : normalized; + final String title = _titleFromSummary(payload); final RelationshipMoment moment = RelationshipMoment( id: 'm-${_uuid.v4()}', personId: personId, title: title, - summary: normalized, + summary: payload, at: DateTime.now(), type: type, ); @@ -108,6 +134,18 @@ class LocalRepository extends AsyncNotifier { await _setState(current.copyWith(moments: moments)); } + Future updateMoment(RelationshipMoment moment) async { + if (moment.summary.trim().isEmpty) { + throw ArgumentError('Capture summary cannot be empty'); + } + + final LocalDataState current = _requireState(); + final List updated = current.moments + .map((RelationshipMoment item) => item.id == moment.id ? moment : item) + .toList(growable: false); + await _setState(current.copyWith(moments: updated)); + } + Future deleteMoment(String momentId) async { final LocalDataState current = _requireState(); final List moments = current.moments @@ -116,6 +154,126 @@ class LocalRepository extends AsyncNotifier { await _setState(current.copyWith(moments: moments)); } + Future addIdea({ + required IdeaType type, + required String title, + required String details, + String? personId, + }) async { + final String normalizedTitle = title.trim(); + if (normalizedTitle.isEmpty) { + throw ArgumentError('Idea title is required'); + } + + final LocalDataState current = _requireState(); + final RelationshipIdea idea = RelationshipIdea( + id: 'i-${_uuid.v4()}', + personId: personId, + type: type, + title: normalizedTitle, + details: details.trim(), + createdAt: DateTime.now(), + ); + + final List ideas = [ + idea, + ...current.ideas, + ]; + await _setState(current.copyWith(ideas: ideas)); + } + + Future updateIdea(RelationshipIdea idea) async { + if (idea.title.trim().isEmpty) { + throw ArgumentError('Idea title is required'); + } + + final LocalDataState current = _requireState(); + final List ideas = current.ideas + .map((RelationshipIdea item) => item.id == idea.id ? idea : item) + .toList(growable: false); + await _setState(current.copyWith(ideas: ideas)); + } + + Future toggleIdeaArchived(String ideaId) async { + final LocalDataState current = _requireState(); + final List ideas = current.ideas + .map( + (RelationshipIdea item) => item.id == ideaId + ? item.copyWith(isArchived: !item.isArchived) + : item, + ) + .toList(growable: false); + await _setState(current.copyWith(ideas: ideas)); + } + + Future deleteIdea(String ideaId) async { + final LocalDataState current = _requireState(); + final List ideas = current.ideas + .where((RelationshipIdea idea) => idea.id != ideaId) + .toList(growable: false); + await _setState(current.copyWith(ideas: ideas)); + } + + Future addReminder({ + required String title, + required ReminderCadence cadence, + required DateTime nextAt, + String? personId, + }) async { + final String normalizedTitle = title.trim(); + if (normalizedTitle.isEmpty) { + throw ArgumentError('Reminder title is required'); + } + + final LocalDataState current = _requireState(); + final ReminderRule reminder = ReminderRule( + id: 'r-${_uuid.v4()}', + personId: personId, + title: normalizedTitle, + cadence: cadence, + nextAt: nextAt, + enabled: true, + ); + + final List reminders = [ + reminder, + ...current.reminders, + ]; + await _setState(current.copyWith(reminders: reminders)); + } + + Future updateReminder(ReminderRule reminder) async { + if (reminder.title.trim().isEmpty) { + throw ArgumentError('Reminder title is required'); + } + + final LocalDataState current = _requireState(); + final List reminders = current.reminders + .map((ReminderRule item) => item.id == reminder.id ? reminder : item) + .toList(growable: false); + await _setState(current.copyWith(reminders: reminders)); + } + + Future toggleReminderEnabled(String reminderId) async { + final LocalDataState current = _requireState(); + final List reminders = current.reminders + .map( + (ReminderRule item) => item.id == reminderId + ? item.copyWith(enabled: !item.enabled) + : item, + ) + .toList(growable: false); + await _setState(current.copyWith(reminders: reminders)); + } + + Future deleteReminder(String reminderId) async { + final LocalDataState current = _requireState(); + final List reminders = current.reminders + .where((ReminderRule reminder) => reminder.id != reminderId) + .toList(growable: false); + await _setState(current.copyWith(reminders: reminders)); + } + Future toggleTaskDone(String taskId) async { final LocalDataState current = _requireState(); final List tasks = current.tasks diff --git a/lib/features/reminders/reminders_view.dart b/lib/features/reminders/reminders_view.dart new file mode 100644 index 0000000..d3e627d --- /dev/null +++ b/lib/features/reminders/reminders_view.dart @@ -0,0 +1,400 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/config/app_theme.dart'; +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 RemindersView extends ConsumerWidget { + const RemindersView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final AsyncValue localData = ref.watch( + localRepositoryProvider, + ); + + return localData.when( + data: (LocalDataState data) { + final List reminders = data.reminders; + final Map peopleById = { + for (final PersonProfile person in data.people) person.id: person, + }; + + return Padding( + padding: const EdgeInsets.fromLTRB(28, 24, 28, 28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Reminders', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 6), + Text( + 'Set recurring nudges so good intentions become habits.', + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith(color: AppTheme.textSecondary), + ), + ], + ), + ), + FilledButton.icon( + onPressed: () => _addReminder(context, ref, data.people), + icon: const Icon(Icons.alarm_add_rounded), + label: const Text('Add Reminder'), + ), + ], + ), + const SizedBox(height: 16), + Expanded( + child: ListView.separated( + itemCount: reminders.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (BuildContext context, int index) { + final ReminderRule reminder = reminders[index]; + final PersonProfile? person = reminder.personId == null + ? null + : peopleById[reminder.personId!]; + + return FrostedCard( + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + reminder.title, + style: Theme.of( + context, + ).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + '${_labelForCadence(reminder.cadence)} • ${_formatDateTime(reminder.nextAt)}', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: AppTheme.textSecondary), + ), + const SizedBox(height: 4), + Text( + person == null ? 'Unassigned' : person.name, + style: Theme.of(context).textTheme.labelLarge + ?.copyWith(color: AppTheme.textSecondary), + ), + ], + ), + ), + Switch( + value: reminder.enabled, + onChanged: (_) { + ref + .read(localRepositoryProvider.notifier) + .toggleReminderEnabled(reminder.id); + }, + ), + IconButton( + onPressed: () => _editReminder( + context, + ref, + data.people, + reminder, + ), + icon: const Icon(Icons.edit_rounded), + tooltip: 'Edit', + ), + IconButton( + onPressed: () { + ref + .read(localRepositoryProvider.notifier) + .deleteReminder(reminder.id); + }, + icon: const Icon(Icons.delete_outline_rounded), + tooltip: 'Delete', + ), + ], + ), + ); + }, + ), + ), + ], + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (Object error, StackTrace stackTrace) { + return const Center(child: Text('Unable to load reminders')); + }, + ); + } + + Future _addReminder( + BuildContext context, + WidgetRef ref, + List people, + ) async { + final _ReminderDraft? draft = await showDialog<_ReminderDraft>( + context: context, + builder: (BuildContext context) => + _ReminderEditorDialog(title: 'Add Reminder', people: people), + ); + + if (draft == null) { + return; + } + + await ref + .read(localRepositoryProvider.notifier) + .addReminder( + title: draft.title, + cadence: draft.cadence, + nextAt: draft.nextAt, + personId: draft.personId, + ); + } + + Future _editReminder( + BuildContext context, + WidgetRef ref, + List people, + ReminderRule reminder, + ) async { + final _ReminderDraft? draft = await showDialog<_ReminderDraft>( + context: context, + builder: (BuildContext context) => _ReminderEditorDialog( + title: 'Edit Reminder', + people: people, + initial: _ReminderDraft( + personId: reminder.personId, + title: reminder.title, + cadence: reminder.cadence, + nextAt: reminder.nextAt, + ), + ), + ); + + if (draft == null) { + return; + } + + await ref + .read(localRepositoryProvider.notifier) + .updateReminder( + reminder.copyWith( + personId: draft.personId, + title: draft.title, + cadence: draft.cadence, + nextAt: draft.nextAt, + ), + ); + } +} + +class _ReminderEditorDialog extends StatefulWidget { + const _ReminderEditorDialog({ + required this.title, + required this.people, + this.initial, + }); + + final String title; + final List people; + final _ReminderDraft? initial; + + @override + State<_ReminderEditorDialog> createState() => _ReminderEditorDialogState(); +} + +class _ReminderEditorDialogState extends State<_ReminderEditorDialog> { + late final TextEditingController _titleController; + late ReminderCadence _cadence; + late DateTime _nextAt; + String? _personId; + + @override + void initState() { + super.initState(); + _titleController = TextEditingController(text: widget.initial?.title ?? ''); + _cadence = widget.initial?.cadence ?? ReminderCadence.weekly; + _nextAt = + widget.initial?.nextAt ?? DateTime.now().add(const Duration(days: 2)); + _personId = widget.initial?.personId; + } + + @override + void dispose() { + _titleController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.title), + content: SizedBox( + width: 420, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _titleController, + decoration: const InputDecoration(labelText: 'Title'), + ), + DropdownButtonFormField( + initialValue: _cadence, + items: ReminderCadence.values + .map( + (ReminderCadence cadence) => + DropdownMenuItem( + value: cadence, + child: Text(_labelForCadence(cadence)), + ), + ) + .toList(growable: false), + onChanged: (ReminderCadence? value) { + if (value == null) { + return; + } + setState(() { + _cadence = value; + }); + }, + decoration: const InputDecoration(labelText: 'Cadence'), + ), + DropdownButtonFormField( + initialValue: _personId, + items: >[ + const DropdownMenuItem( + value: null, + child: Text('Unassigned'), + ), + ...widget.people.map( + (PersonProfile person) => DropdownMenuItem( + value: person.id, + child: Text(person.name), + ), + ), + ], + onChanged: (String? value) { + setState(() { + _personId = value; + }); + }, + decoration: const InputDecoration(labelText: 'Person'), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: Text( + _formatDateTime(_nextAt), + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + OutlinedButton.icon( + onPressed: _pickDateTime, + icon: const Icon(Icons.schedule_rounded), + label: const Text('Pick Time'), + ), + ], + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final String title = _titleController.text.trim(); + if (title.isEmpty) { + return; + } + Navigator.of(context).pop( + _ReminderDraft( + personId: _personId, + title: title, + cadence: _cadence, + nextAt: _nextAt, + ), + ); + }, + child: const Text('Save'), + ), + ], + ); + } + + Future _pickDateTime() async { + final DateTime now = DateTime.now(); + final DateTime? date = await showDatePicker( + context: context, + initialDate: _nextAt, + firstDate: now.subtract(const Duration(days: 1)), + lastDate: now.add(const Duration(days: 3650)), + ); + + if (date == null || !mounted) { + return; + } + + final TimeOfDay? time = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(_nextAt), + ); + if (time == null || !mounted) { + return; + } + + setState(() { + _nextAt = DateTime( + date.year, + date.month, + date.day, + time.hour, + time.minute, + ); + }); + } +} + +class _ReminderDraft { + const _ReminderDraft({ + required this.personId, + required this.title, + required this.cadence, + required this.nextAt, + }); + + final String? personId; + final String title; + final ReminderCadence cadence; + final DateTime nextAt; +} + +String _labelForCadence(ReminderCadence cadence) { + return switch (cadence) { + ReminderCadence.daily => 'Daily', + ReminderCadence.weekly => 'Weekly', + ReminderCadence.monthly => 'Monthly', + }; +} + +String _formatDateTime(DateTime value) { + final String month = value.month.toString().padLeft(2, '0'); + final String day = value.day.toString().padLeft(2, '0'); + final String hour = value.hour.toString().padLeft(2, '0'); + final String minute = value.minute.toString().padLeft(2, '0'); + return '$month/$day/${value.year} $hour:$minute'; +} diff --git a/test/features/local/local_repository_test.dart b/test/features/local/local_repository_test.dart index 9a3193c..50ba9f7 100644 --- a/test/features/local/local_repository_test.dart +++ b/test/features/local/local_repository_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:relationship_saver/features/local/local_models.dart'; import 'package:relationship_saver/features/local/local_repository.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -70,4 +71,104 @@ void main() { final afterDelete = await container.read(localRepositoryProvider.future); expect(afterDelete.moments.length, beforeCount); }); + + test('adds, archives, edits, and deletes ideas', () async { + final ProviderContainer container = ProviderContainer(); + addTearDown(container.dispose); + + final LocalDataState state = await container.read( + localRepositoryProvider.future, + ); + final String personId = state.people.first.id; + final int beforeCount = state.ideas.length; + + await container + .read(localRepositoryProvider.notifier) + .addIdea( + type: IdeaType.gift, + title: 'Test idea', + details: 'some details', + personId: personId, + ); + + final LocalDataState afterAdd = await container.read( + localRepositoryProvider.future, + ); + expect(afterAdd.ideas.length, beforeCount + 1); + + final RelationshipIdea inserted = afterAdd.ideas.first; + await container + .read(localRepositoryProvider.notifier) + .toggleIdeaArchived(inserted.id); + + LocalDataState afterArchive = await container.read( + localRepositoryProvider.future, + ); + expect(afterArchive.ideas.first.isArchived, isTrue); + + await container + .read(localRepositoryProvider.notifier) + .updateIdea(afterArchive.ideas.first.copyWith(title: 'Updated idea')); + + afterArchive = await container.read(localRepositoryProvider.future); + expect(afterArchive.ideas.first.title, 'Updated idea'); + + await container + .read(localRepositoryProvider.notifier) + .deleteIdea(inserted.id); + + final LocalDataState afterDelete = await container.read( + localRepositoryProvider.future, + ); + expect(afterDelete.ideas.length, beforeCount); + }); + + test('adds, updates, toggles, and deletes reminders', () async { + final ProviderContainer container = ProviderContainer(); + addTearDown(container.dispose); + + final LocalDataState state = await container.read( + localRepositoryProvider.future, + ); + final String personId = state.people.first.id; + final int beforeCount = state.reminders.length; + + await container + .read(localRepositoryProvider.notifier) + .addReminder( + title: 'Test reminder', + cadence: ReminderCadence.weekly, + nextAt: DateTime.now().add(const Duration(days: 1)), + personId: personId, + ); + + LocalDataState afterAdd = await container.read( + localRepositoryProvider.future, + ); + expect(afterAdd.reminders.length, beforeCount + 1); + + final ReminderRule inserted = afterAdd.reminders.first; + await container + .read(localRepositoryProvider.notifier) + .toggleReminderEnabled(inserted.id); + afterAdd = await container.read(localRepositoryProvider.future); + expect(afterAdd.reminders.first.enabled, isFalse); + + await container + .read(localRepositoryProvider.notifier) + .updateReminder( + afterAdd.reminders.first.copyWith(title: 'Updated reminder'), + ); + afterAdd = await container.read(localRepositoryProvider.future); + expect(afterAdd.reminders.first.title, 'Updated reminder'); + + await container + .read(localRepositoryProvider.notifier) + .deleteReminder(inserted.id); + + final LocalDataState afterDelete = await container.read( + localRepositoryProvider.future, + ); + expect(afterDelete.reminders.length, beforeCount); + }); }