Add persisted local state and baseline CRUD flows

This commit is contained in:
Rijad Zuzo
2026-02-15 15:12:23 +01:00
parent d708aa58d9
commit 4472668474
12 changed files with 1324 additions and 350 deletions
+25
View File
@@ -2,6 +2,31 @@
Updated: 2026-02-15 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 ## Workflow Rule
- After every sensible change, the final step is a Git commit with a clear message. - After every sensible change, the final step is a Git commit with a clear message.
+44 -13
View File
@@ -10,9 +10,14 @@ class DashboardView extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final LocalRepository repository = ref.watch(localRepositoryProvider); final AsyncValue<LocalDataState> localData = ref.watch(
final DashboardSummary summary = repository.summary(); localRepositoryProvider,
final List<DashboardTask> tasks = repository.tasks(); );
return localData.when(
data: (LocalDataState data) {
final DashboardSummary summary = data.summary();
final List<DashboardTask> tasks = data.tasks;
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(28, 24, 28, 36), padding: const EdgeInsets.fromLTRB(28, 24, 28, 36),
@@ -64,13 +69,33 @@ class DashboardView extends ConsumerWidget {
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.titleLarge,
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
...tasks.map((DashboardTask task) => _TaskRow(task: task)), ...tasks.map(
(DashboardTask task) => _TaskRow(
task: task,
onToggleDone: () {
ref
.read(localRepositoryProvider.notifier)
.toggleTaskDone(task.id);
},
),
),
], ],
), ),
), ),
], ],
), ),
); );
},
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 { class _TaskRow extends StatelessWidget {
const _TaskRow({required this.task}); const _TaskRow({required this.task, required this.onToggleDone});
final DashboardTask task; final DashboardTask task;
final VoidCallback onToggleDone;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -141,13 +167,16 @@ class _TaskRow extends StatelessWidget {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Container( InkWell(
margin: const EdgeInsets.only(top: 2), onTap: onToggleDone,
width: 10, borderRadius: BorderRadius.circular(20),
height: 10, child: Icon(
decoration: const BoxDecoration( task.done
color: Color(0xFF1AB6C8), ? Icons.check_circle_rounded
shape: BoxShape.circle, : Icons.radio_button_unchecked_rounded,
color: task.done
? const Color(0xFF1D9C66)
: const Color(0xFF1AB6C8),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -157,7 +186,9 @@ class _TaskRow extends StatelessWidget {
children: <Widget>[ children: <Widget>[
Text( Text(
task.title, 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), const SizedBox(height: 4),
Text( Text(
+292
View File
@@ -1,3 +1,8 @@
// ignore_for_file: sort_constructors_first
import 'package:flutter/foundation.dart';
@immutable
class PersonProfile { class PersonProfile {
const PersonProfile({ const PersonProfile({
required this.id, required this.id,
@@ -16,8 +21,55 @@ class PersonProfile {
final DateTime nextMoment; final DateTime nextMoment;
final List<String> tags; final List<String> tags;
final String notes; final String notes;
PersonProfile copyWith({
String? id,
String? name,
String? relationship,
int? affinityScore,
DateTime? nextMoment,
List<String>? 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<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'name': name,
'relationship': relationship,
'affinityScore': affinityScore,
'nextMoment': nextMoment.toUtc().toIso8601String(),
'tags': tags,
'notes': notes,
};
}
factory PersonProfile.fromJson(Map<String, dynamic> 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<dynamic>)
.map((dynamic tag) => '$tag')
.toList(),
notes: json['notes'] as String,
);
}
} }
@immutable
class RelationshipMoment { class RelationshipMoment {
const RelationshipMoment({ const RelationshipMoment({
required this.id, required this.id,
@@ -34,22 +86,102 @@ class RelationshipMoment {
final String summary; final String summary;
final DateTime at; final DateTime at;
final String type; 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<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'personId': personId,
'title': title,
'summary': summary,
'at': at.toUtc().toIso8601String(),
'type': type,
};
}
factory RelationshipMoment.fromJson(Map<String, dynamic> 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 { class DashboardTask {
const DashboardTask({ const DashboardTask({
required this.id,
required this.title, required this.title,
required this.description, required this.description,
required this.when, required this.when,
this.done = false, this.done = false,
}); });
final String id;
final String title; final String title;
final String description; final String description;
final DateTime when; final DateTime when;
final bool done; 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<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'title': title,
'description': description,
'when': when.toUtc().toIso8601String(),
'done': done,
};
}
factory DashboardTask.fromJson(Map<String, dynamic> 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 { class DashboardSummary {
const DashboardSummary({ const DashboardSummary({
required this.activePeople, required this.activePeople,
@@ -63,3 +195,163 @@ class DashboardSummary {
final int pendingIdeas; final int pendingIdeas;
final int weeklyConsistency; final int weeklyConsistency;
} }
@immutable
class LocalDataState {
const LocalDataState({
required this.people,
required this.moments,
required this.tasks,
});
final List<PersonProfile> people;
final List<RelationshipMoment> moments;
final List<DashboardTask> tasks;
LocalDataState copyWith({
List<PersonProfile>? people,
List<RelationshipMoment>? moments,
List<DashboardTask>? 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<int>(
0,
(int acc, PersonProfile person) =>
acc + (person.tags.isNotEmpty ? 1 : 0),
),
weeklyConsistency: moments.isEmpty
? 0
: (70 + moments.length * 4).clamp(0, 100),
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'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<String, dynamic> json) {
return LocalDataState(
people: (json['people'] as List<dynamic>)
.map(
(dynamic person) =>
PersonProfile.fromJson(person as Map<String, dynamic>),
)
.toList(growable: false),
moments: (json['moments'] as List<dynamic>)
.map(
(dynamic moment) =>
RelationshipMoment.fromJson(moment as Map<String, dynamic>),
)
.toList(growable: false),
tasks: (json['tasks'] as List<dynamic>)
.map(
(dynamic task) =>
DashboardTask.fromJson(task as Map<String, dynamic>),
)
.toList(growable: false),
);
}
static LocalDataState seed() {
return LocalDataState(
people: <PersonProfile>[
PersonProfile(
id: 'p-ava',
name: 'Ava Hart',
relationship: 'Partner',
affinityScore: 92,
nextMoment: DateTime(2026, 2, 16, 19, 30),
tags: <String>['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: <String>['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: <String>['plants', 'retro music'],
notes: 'Birthday prep should start this week.',
),
],
moments: <RelationshipMoment>[
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>[
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),
),
],
);
}
}
+163 -93
View File
@@ -1,105 +1,175 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/features/local/local_models.dart'; import 'package:relationship_saver/features/local/local_models.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
class LocalRepository { /// Persisted local data source for offline-first product state.
const LocalRepository(); class LocalRepository extends AsyncNotifier<LocalDataState> {
static const String _storageKey = 'local_data_state_v1';
static const String _schemaVersionKey = 'local_data_schema_version';
static const int _schemaVersion = 1;
List<PersonProfile> people() { static const Uuid _uuid = Uuid();
return <PersonProfile>[
PersonProfile( @override
id: 'p-ava', Future<LocalDataState> build() async {
name: 'Ava Hart', final SharedPreferences prefs = await SharedPreferences.getInstance();
relationship: 'Partner', await _migrateIfNeeded(prefs);
affinityScore: 92,
nextMoment: DateTime(2026, 2, 16, 19, 30), final String? raw = prefs.getString(_storageKey);
tags: <String>['coffee', 'books', 'slow mornings'], if (raw == null || raw.isEmpty) {
notes: 'Prefers thoughtful plans over expensive plans.', final LocalDataState seeded = LocalDataState.seed();
), await _persist(seeded);
PersonProfile( return seeded;
id: 'p-jordan',
name: 'Jordan Lee',
relationship: 'Close Friend',
affinityScore: 78,
nextMoment: DateTime(2026, 2, 18, 18, 0),
tags: <String>['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: <String>['plants', 'retro music'],
notes: 'Birthday prep should start this week.',
),
];
} }
List<RelationshipMoment> moments() { try {
return <RelationshipMoment>[ final Map<String, dynamic> json = jsonDecode(raw) as Map<String, dynamic>;
RelationshipMoment( return LocalDataState.fromJson(json);
id: 'm-1', } on FormatException {
personId: 'p-ava', final LocalDataState fallback = LocalDataState.seed();
title: 'Sunday Walk Tradition', await _persist(fallback);
summary: 'Short walk + no-phone hour worked really well.', return fallback;
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<DashboardTask> tasks() { Future<void> addPerson({
return <DashboardTask>[ required String name,
DashboardTask( required String relationship,
title: 'Plan Friday dinner with Ava', required String notes,
description: 'Keep it low-key: ramen + bookstore stop.', required List<String> tags,
when: DateTime(2026, 2, 16, 17, 0), DateTime? nextMoment,
), }) async {
DashboardTask( final LocalDataState current = _requireState();
title: 'Send race-day encouragement to Jordan', final PersonProfile person = PersonProfile(
description: 'Voice note before 8:00 AM.', id: 'p-${_uuid.v4()}',
when: DateTime(2026, 2, 17, 7, 30), name: name.trim(),
), relationship: relationship.trim(),
DashboardTask( affinityScore: 75,
title: 'Finalize Mila birthday shortlist', nextMoment: nextMoment ?? DateTime.now().add(const Duration(days: 3)),
description: 'Pick 1 meaningful gift and 1 backup.', tags: tags,
when: DateTime(2026, 2, 18, 20, 0), notes: notes.trim(),
),
];
}
DashboardSummary summary() {
return DashboardSummary(
activePeople: people().length,
upcomingPlans: people()
.where(
(PersonProfile p) => p.nextMoment.isAfter(DateTime(2026, 2, 14)),
)
.length,
pendingIdeas: 6,
weeklyConsistency: 82,
); );
final List<PersonProfile> people = <PersonProfile>[
person,
...current.people,
];
await _setState(current.copyWith(people: people));
}
Future<void> updatePerson(PersonProfile person) async {
final LocalDataState current = _requireState();
final List<PersonProfile> updated = current.people
.map((PersonProfile item) => item.id == person.id ? person : item)
.toList(growable: false);
await _setState(current.copyWith(people: updated));
}
Future<void> deletePerson(String personId) async {
final LocalDataState current = _requireState();
final List<PersonProfile> people = current.people
.where((PersonProfile person) => person.id != personId)
.toList(growable: false);
final List<RelationshipMoment> moments = current.moments
.where((RelationshipMoment moment) => moment.personId != personId)
.toList(growable: false);
await _setState(current.copyWith(people: people, moments: moments));
}
Future<void> 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<RelationshipMoment> moments = <RelationshipMoment>[
moment,
...current.moments,
];
await _setState(current.copyWith(moments: moments));
}
Future<void> deleteMoment(String momentId) async {
final LocalDataState current = _requireState();
final List<RelationshipMoment> moments = current.moments
.where((RelationshipMoment moment) => moment.id != momentId)
.toList(growable: false);
await _setState(current.copyWith(moments: moments));
}
Future<void> toggleTaskDone(String taskId) async {
final LocalDataState current = _requireState();
final List<DashboardTask> 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<void> _setState(LocalDataState next) async {
state = AsyncData<LocalDataState>(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<void> _persist(LocalDataState state) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setString(_storageKey, jsonEncode(state.toJson()));
await prefs.setInt(_schemaVersionKey, _schemaVersion);
}
Future<void> _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<String> words = summary.split(RegExp(r'\s+'));
final int take = words.length < 5 ? words.length : 5;
return words.take(take).join(' ');
} }
} }
final Provider<LocalRepository> localRepositoryProvider = final AsyncNotifierProvider<LocalRepository, LocalDataState>
Provider<LocalRepository>((Ref ref) { localRepositoryProvider =
return const LocalRepository(); AsyncNotifierProvider<LocalRepository, LocalDataState>(LocalRepository.new);
});
+113 -11
View File
@@ -5,23 +5,54 @@ import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/local/local_repository.dart'; import 'package:relationship_saver/features/local/local_repository.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart'; import 'package:relationship_saver/features/shared/frosted_card.dart';
class MomentsView extends ConsumerWidget { class MomentsView extends ConsumerStatefulWidget {
const MomentsView({super.key}); const MomentsView({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<MomentsView> createState() => _MomentsViewState();
final LocalRepository repository = ref.watch(localRepositoryProvider); }
final List<RelationshipMoment> moments = repository.moments();
class _MomentsViewState extends ConsumerState<MomentsView> {
final TextEditingController _captureController = TextEditingController();
String? _selectedPersonId;
@override
void dispose() {
_captureController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final AsyncValue<LocalDataState> localData = ref.watch(
localRepositoryProvider,
);
return localData.when(
data: (LocalDataState data) {
final List<PersonProfile> people = data.people;
final List<RelationshipMoment> moments = data.moments;
final Map<String, PersonProfile> peopleById = <String, PersonProfile>{ final Map<String, PersonProfile> peopleById = <String, PersonProfile>{
for (final PersonProfile person in repository.people()) person.id: person, 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( return Padding(
padding: const EdgeInsets.fromLTRB(28, 24, 28, 28), padding: const EdgeInsets.fromLTRB(28, 24, 28, 28),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text('Moments', style: Theme.of(context).textTheme.headlineMedium), Text(
'Moments',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
'Capture wins and signals from everyday interactions.', 'Capture wins and signals from everyday interactions.',
@@ -31,10 +62,13 @@ class MomentsView extends ConsumerWidget {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
FrostedCard( FrostedCard(
child: Row( child: Column(
children: <Widget>[
Row(
children: <Widget>[ children: <Widget>[
Expanded( Expanded(
child: TextField( child: TextField(
controller: _captureController,
decoration: InputDecoration( decoration: InputDecoration(
hintText: hintText:
'Quick capture: what happened, how it felt, what to repeat...', 'Quick capture: what happened, how it felt, what to repeat...',
@@ -44,13 +78,47 @@ class MomentsView extends ConsumerWidget {
), ),
), ),
), ),
const SizedBox(width: 8),
if (people.isNotEmpty)
DropdownButton<String>(
value: activePerson,
hint: const Text('Person'),
items: people
.map(
(PersonProfile person) =>
DropdownMenuItem<String>(
value: person.id,
child: Text(person.name),
),
)
.toList(growable: false),
onChanged: (String? value) {
setState(() {
_selectedPersonId = value;
});
},
),
const SizedBox(width: 8),
FilledButton.icon( FilledButton.icon(
onPressed: () {}, onPressed: people.isEmpty || activePerson == null
? null
: () => _addCapture(activePerson),
icon: const Icon(Icons.add_rounded), icon: const Icon(Icons.add_rounded),
label: const Text('Add Capture'), 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(height: 16), const SizedBox(height: 16),
Expanded( Expanded(
@@ -80,7 +148,9 @@ class MomentsView extends ConsumerWidget {
children: <Widget>[ children: <Widget>[
Text( Text(
moment.title, moment.title,
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(
context,
).textTheme.titleMedium,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
@@ -97,11 +167,25 @@ class MomentsView extends ConsumerWidget {
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Text( Text(
'${moment.at.month}/${moment.at.day}', '${moment.at.month}/${moment.at.day}',
style: Theme.of(context).textTheme.labelLarge?.copyWith( style: Theme.of(context).textTheme.labelLarge
color: AppTheme.textSecondary, ?.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',
),
],
), ),
], ],
), ),
@@ -112,5 +196,23 @@ class MomentsView extends ConsumerWidget {
], ],
), ),
); );
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (Object error, StackTrace stackTrace) {
return const Center(child: Text('Unable to load moments'));
},
);
}
Future<void> _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();
} }
} }
+313 -11
View File
@@ -12,6 +12,10 @@ class SelectedPersonIdNotifier extends Notifier<String?> {
void select(String id) { void select(String id) {
state = id; state = id;
} }
void clear() {
state = null;
}
} }
final NotifierProvider<SelectedPersonIdNotifier, String?> final NotifierProvider<SelectedPersonIdNotifier, String?>
@@ -24,9 +28,17 @@ class PeopleView extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final List<PersonProfile> people = ref final AsyncValue<LocalDataState> localData = ref.watch(
.watch(localRepositoryProvider) localRepositoryProvider,
.people(); );
return localData.when(
data: (LocalDataState data) {
final List<PersonProfile> people = data.people;
if (people.isEmpty) {
return _EmptyPeopleView(onAdd: () => _handleAddPerson(context, ref));
}
final String selectedId = final String selectedId =
ref.watch(selectedPersonIdProvider) ?? people.first.id; ref.watch(selectedPersonIdProvider) ?? people.first.id;
final PersonProfile selected = people.firstWhere( final PersonProfile selected = people.firstWhere(
@@ -40,19 +52,36 @@ class PeopleView extends ConsumerWidget {
children: <Widget>[ children: <Widget>[
Expanded( Expanded(
flex: 5, flex: 5,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text( Text(
'People', 'People',
style: Theme.of(context).textTheme.headlineMedium, style: Theme.of(
context,
).textTheme.headlineMedium,
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
'Track what matters to each relationship and follow through.', 'Track what matters to each relationship and follow through.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith( style: Theme.of(context).textTheme.bodyLarge
color: AppTheme.textSecondary, ?.copyWith(color: AppTheme.textSecondary),
), ),
],
),
),
FilledButton.icon(
onPressed: () => _handleAddPerson(context, ref),
icon: const Icon(Icons.person_add_alt_1_rounded),
label: const Text('Add'),
),
],
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
Expanded( Expanded(
@@ -77,7 +106,8 @@ class PeopleView extends ConsumerWidget {
const SizedBox(width: 14), const SizedBox(width: 14),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment:
CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text( Text(
person.name, person.name,
@@ -120,18 +150,164 @@ class PeopleView extends ConsumerWidget {
const SizedBox(width: 18), const SizedBox(width: 18),
Expanded( Expanded(
flex: 6, flex: 6,
child: FrostedCard(child: _PersonDetail(person: selected)), 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<void> _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<void> _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<void> _handleDeletePerson(
BuildContext context,
WidgetRef ref,
String personId,
) async {
final bool? confirmed = await showDialog<bool>(
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: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
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: <Widget>[
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 { class _PersonDetail extends StatelessWidget {
const _PersonDetail({required this.person}); const _PersonDetail({
required this.person,
required this.onEdit,
required this.onDelete,
});
final PersonProfile person; final PersonProfile person;
final VoidCallback onEdit;
final VoidCallback onDelete;
@override @override
Widget build(BuildContext context) { 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), const SizedBox(height: 22),
@@ -178,7 +363,7 @@ class _PersonDetail extends StatelessWidget {
runSpacing: 8, runSpacing: 8,
children: person.tags children: person.tags
.map((String tag) => _DetailTag(label: tag)) .map((String tag) => _DetailTag(label: tag))
.toList(), .toList(growable: false),
), ),
const SizedBox(height: 18), const SizedBox(height: 18),
Text('Notes', style: Theme.of(context).textTheme.titleMedium), 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: <Widget>[
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: <Widget>[
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<String> tags;
}
class _DetailTag extends StatelessWidget { class _DetailTag extends StatelessWidget {
const _DetailTag({required this.label}); const _DetailTag({required this.label});
+21 -6
View File
@@ -40,9 +40,24 @@ class SignalsController extends AsyncNotifier<SignalsFeed> {
try { try {
return await ref.read(backendGatewayProvider).getSignalsFeed(limit: 20); return await ref.read(backendGatewayProvider).getSignalsFeed(limit: 20);
} catch (_) { } catch (_) {
final List<PersonProfile> people = ref final LocalDataState localData = await ref.read(
.read(localRepositoryProvider) localRepositoryProvider.future,
.people(); );
final List<PersonProfile> 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: <String>[],
notes: '',
);
final PersonProfile secondaryPerson = people.length > 1
? people[1]
: defaultPerson;
final DateTime now = DateTime.now().toUtc(); final DateTime now = DateTime.now().toUtc();
return SignalsFeed( return SignalsFeed(
cursor: 'fallback-signals', cursor: 'fallback-signals',
@@ -50,9 +65,9 @@ class SignalsController extends AsyncNotifier<SignalsFeed> {
SignalItem( SignalItem(
id: 'fallback-1', id: 'fallback-1',
type: 'recommendation', 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.', description: 'A short voice memo can keep momentum.',
personId: people.first.id, personId: defaultPerson.id,
createdAt: now, createdAt: now,
), ),
SignalItem( SignalItem(
@@ -60,7 +75,7 @@ class SignalsController extends AsyncNotifier<SignalsFeed> {
type: 'trend', type: 'trend',
title: 'Gift idea: practical + personal is trending', title: 'Gift idea: practical + personal is trending',
description: 'Combine utility with one sentimental detail.', description: 'Combine utility with one sentimental detail.',
personId: people.last.id, personId: secondaryPerson.id,
createdAt: now.subtract(const Duration(hours: 2)), createdAt: now.subtract(const Duration(hours: 2)),
), ),
], ],
@@ -6,7 +6,9 @@ import FlutterMacOS
import Foundation import Foundation
import flutter_secure_storage_darwin import flutter_secure_storage_darwin
import shared_preferences_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
} }
+56
View File
@@ -648,6 +648,62 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.2.1" 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: shelf:
dependency: transitive dependency: transitive
description: description:
+1
View File
@@ -41,6 +41,7 @@ dependencies:
uuid: ^4.5.2 uuid: ^4.5.2
clock: ^1.1.2 clock: ^1.1.2
flutter_riverpod: ^3.2.1 flutter_riverpod: ^3.2.1
shared_preferences: ^2.5.4
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -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(<String, Object>{});
});
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: <String>['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);
});
}
+5
View File
@@ -1,8 +1,13 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:relationship_saver/main.dart'; import 'package:relationship_saver/main.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() { void main() {
setUpAll(() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
});
testWidgets('renders dashboard flow', (WidgetTester tester) async { testWidgets('renders dashboard flow', (WidgetTester tester) async {
await tester.pumpWidget(const ProviderScope(child: RelationshipSaverApp())); await tester.pumpWidget(const ProviderScope(child: RelationshipSaverApp()));
await tester.pumpAndSettle(); await tester.pumpAndSettle();