Add persisted local state and baseline CRUD flows
This commit is contained in:
@@ -1,3 +1,8 @@
|
||||
// ignore_for_file: sort_constructors_first
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
@immutable
|
||||
class PersonProfile {
|
||||
const PersonProfile({
|
||||
required this.id,
|
||||
@@ -16,8 +21,55 @@ class PersonProfile {
|
||||
final DateTime nextMoment;
|
||||
final List<String> tags;
|
||||
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 {
|
||||
const RelationshipMoment({
|
||||
required this.id,
|
||||
@@ -34,22 +86,102 @@ class RelationshipMoment {
|
||||
final String summary;
|
||||
final DateTime at;
|
||||
final String type;
|
||||
|
||||
RelationshipMoment copyWith({
|
||||
String? id,
|
||||
String? personId,
|
||||
String? title,
|
||||
String? summary,
|
||||
DateTime? at,
|
||||
String? type,
|
||||
}) {
|
||||
return RelationshipMoment(
|
||||
id: id ?? this.id,
|
||||
personId: personId ?? this.personId,
|
||||
title: title ?? this.title,
|
||||
summary: summary ?? this.summary,
|
||||
at: at ?? this.at,
|
||||
type: type ?? this.type,
|
||||
);
|
||||
}
|
||||
|
||||
Map<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 {
|
||||
const DashboardTask({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.when,
|
||||
this.done = false,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final String description;
|
||||
final DateTime when;
|
||||
final bool done;
|
||||
|
||||
DashboardTask copyWith({
|
||||
String? id,
|
||||
String? title,
|
||||
String? description,
|
||||
DateTime? when,
|
||||
bool? done,
|
||||
}) {
|
||||
return DashboardTask(
|
||||
id: id ?? this.id,
|
||||
title: title ?? this.title,
|
||||
description: description ?? this.description,
|
||||
when: when ?? this.when,
|
||||
done: done ?? this.done,
|
||||
);
|
||||
}
|
||||
|
||||
Map<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 {
|
||||
const DashboardSummary({
|
||||
required this.activePeople,
|
||||
@@ -63,3 +195,163 @@ class DashboardSummary {
|
||||
final int pendingIdeas;
|
||||
final int weeklyConsistency;
|
||||
}
|
||||
|
||||
@immutable
|
||||
class LocalDataState {
|
||||
const LocalDataState({
|
||||
required this.people,
|
||||
required this.moments,
|
||||
required this.tasks,
|
||||
});
|
||||
|
||||
final List<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:relationship_saver/features/local/local_models.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class LocalRepository {
|
||||
const LocalRepository();
|
||||
/// Persisted local data source for offline-first product state.
|
||||
class LocalRepository extends AsyncNotifier<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() {
|
||||
return <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.',
|
||||
),
|
||||
];
|
||||
static const Uuid _uuid = Uuid();
|
||||
|
||||
@override
|
||||
Future<LocalDataState> build() async {
|
||||
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
await _migrateIfNeeded(prefs);
|
||||
|
||||
final String? raw = prefs.getString(_storageKey);
|
||||
if (raw == null || raw.isEmpty) {
|
||||
final LocalDataState seeded = LocalDataState.seed();
|
||||
await _persist(seeded);
|
||||
return seeded;
|
||||
}
|
||||
|
||||
try {
|
||||
final Map<String, dynamic> json = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return LocalDataState.fromJson(json);
|
||||
} on FormatException {
|
||||
final LocalDataState fallback = LocalDataState.seed();
|
||||
await _persist(fallback);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
List<RelationshipMoment> moments() {
|
||||
return <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',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<DashboardTask> tasks() {
|
||||
return <DashboardTask>[
|
||||
DashboardTask(
|
||||
title: 'Plan Friday dinner with Ava',
|
||||
description: 'Keep it low-key: ramen + bookstore stop.',
|
||||
when: DateTime(2026, 2, 16, 17, 0),
|
||||
),
|
||||
DashboardTask(
|
||||
title: 'Send race-day encouragement to Jordan',
|
||||
description: 'Voice note before 8:00 AM.',
|
||||
when: DateTime(2026, 2, 17, 7, 30),
|
||||
),
|
||||
DashboardTask(
|
||||
title: 'Finalize Mila birthday shortlist',
|
||||
description: 'Pick 1 meaningful gift and 1 backup.',
|
||||
when: DateTime(2026, 2, 18, 20, 0),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
DashboardSummary summary() {
|
||||
return DashboardSummary(
|
||||
activePeople: people().length,
|
||||
upcomingPlans: people()
|
||||
.where(
|
||||
(PersonProfile p) => p.nextMoment.isAfter(DateTime(2026, 2, 14)),
|
||||
)
|
||||
.length,
|
||||
pendingIdeas: 6,
|
||||
weeklyConsistency: 82,
|
||||
Future<void> addPerson({
|
||||
required String name,
|
||||
required String relationship,
|
||||
required String notes,
|
||||
required List<String> tags,
|
||||
DateTime? nextMoment,
|
||||
}) async {
|
||||
final LocalDataState current = _requireState();
|
||||
final PersonProfile person = PersonProfile(
|
||||
id: 'p-${_uuid.v4()}',
|
||||
name: name.trim(),
|
||||
relationship: relationship.trim(),
|
||||
affinityScore: 75,
|
||||
nextMoment: nextMoment ?? DateTime.now().add(const Duration(days: 3)),
|
||||
tags: tags,
|
||||
notes: notes.trim(),
|
||||
);
|
||||
|
||||
final List<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 =
|
||||
Provider<LocalRepository>((Ref ref) {
|
||||
return const LocalRepository();
|
||||
});
|
||||
final AsyncNotifierProvider<LocalRepository, LocalDataState>
|
||||
localRepositoryProvider =
|
||||
AsyncNotifierProvider<LocalRepository, LocalDataState>(LocalRepository.new);
|
||||
|
||||
Reference in New Issue
Block a user