Add persisted local state and baseline CRUD flows
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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});
|
||||||
|
|
||||||
|
|||||||
@@ -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"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user