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
+92 -61
View File
@@ -10,66 +10,91 @@ class DashboardView extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final LocalRepository repository = ref.watch(localRepositoryProvider);
final DashboardSummary summary = repository.summary();
final List<DashboardTask> tasks = repository.tasks();
final AsyncValue<LocalDataState> localData = ref.watch(
localRepositoryProvider,
);
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(28, 24, 28, 36),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text('Today', style: Theme.of(context).textTheme.headlineMedium),
const SizedBox(height: 8),
Text(
'Focus on consistency over intensity. Small moments compound.',
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 22),
Wrap(
spacing: 16,
runSpacing: 16,
return localData.when(
data: (LocalDataState data) {
final DashboardSummary summary = data.summary();
final List<DashboardTask> tasks = data.tasks;
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(28, 24, 28, 36),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_StatCard(
label: 'Active People',
value: '${summary.activePeople}',
accent: const Color(0xFF16B8CA),
Text('Today', style: Theme.of(context).textTheme.headlineMedium),
const SizedBox(height: 8),
Text(
'Focus on consistency over intensity. Small moments compound.',
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
),
_StatCard(
label: 'Upcoming Plans',
value: '${summary.upcomingPlans}',
accent: const Color(0xFF2B7FFF),
const SizedBox(height: 22),
Wrap(
spacing: 16,
runSpacing: 16,
children: <Widget>[
_StatCard(
label: 'Active People',
value: '${summary.activePeople}',
accent: const Color(0xFF16B8CA),
),
_StatCard(
label: 'Upcoming Plans',
value: '${summary.upcomingPlans}',
accent: const Color(0xFF2B7FFF),
),
_StatCard(
label: 'Pending Ideas',
value: '${summary.pendingIdeas}',
accent: const Color(0xFFFFA447),
),
_StatCard(
label: 'Weekly Rhythm',
value: '${summary.weeklyConsistency}%',
accent: const Color(0xFF30B66A),
),
],
),
_StatCard(
label: 'Pending Ideas',
value: '${summary.pendingIdeas}',
accent: const Color(0xFFFFA447),
),
_StatCard(
label: 'Weekly Rhythm',
value: '${summary.weeklyConsistency}%',
accent: const Color(0xFF30B66A),
const SizedBox(height: 24),
FrostedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Next Moves',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 14),
...tasks.map(
(DashboardTask task) => _TaskRow(
task: task,
onToggleDone: () {
ref
.read(localRepositoryProvider.notifier)
.toggleTaskDone(task.id);
},
),
),
],
),
),
],
),
const SizedBox(height: 24),
FrostedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Next Moves',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 14),
...tasks.map((DashboardTask task) => _TaskRow(task: task)),
],
),
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (Object error, StackTrace stackTrace) {
return Center(
child: Text(
'Could not load local data.',
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
);
},
);
}
}
@@ -124,9 +149,10 @@ class _StatCard extends StatelessWidget {
}
class _TaskRow extends StatelessWidget {
const _TaskRow({required this.task});
const _TaskRow({required this.task, required this.onToggleDone});
final DashboardTask task;
final VoidCallback onToggleDone;
@override
Widget build(BuildContext context) {
@@ -141,13 +167,16 @@ class _TaskRow extends StatelessWidget {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Container(
margin: const EdgeInsets.only(top: 2),
width: 10,
height: 10,
decoration: const BoxDecoration(
color: Color(0xFF1AB6C8),
shape: BoxShape.circle,
InkWell(
onTap: onToggleDone,
borderRadius: BorderRadius.circular(20),
child: Icon(
task.done
? Icons.check_circle_rounded
: Icons.radio_button_unchecked_rounded,
color: task.done
? const Color(0xFF1D9C66)
: const Color(0xFF1AB6C8),
),
),
const SizedBox(width: 12),
@@ -157,7 +186,9 @@ class _TaskRow extends StatelessWidget {
children: <Widget>[
Text(
task.title,
style: Theme.of(context).textTheme.titleMedium,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
decoration: task.done ? TextDecoration.lineThrough : null,
),
),
const SizedBox(height: 4),
Text(
+292
View File
@@ -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),
),
],
);
}
}
+165 -95
View File
@@ -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);
+198 -96
View File
@@ -5,112 +5,214 @@ import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/local/local_repository.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart';
class MomentsView extends ConsumerWidget {
class MomentsView extends ConsumerStatefulWidget {
const MomentsView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final LocalRepository repository = ref.watch(localRepositoryProvider);
final List<RelationshipMoment> moments = repository.moments();
final Map<String, PersonProfile> peopleById = <String, PersonProfile>{
for (final PersonProfile person in repository.people()) person.id: person,
};
ConsumerState<MomentsView> createState() => _MomentsViewState();
}
return Padding(
padding: const EdgeInsets.fromLTRB(28, 24, 28, 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text('Moments', style: Theme.of(context).textTheme.headlineMedium),
const SizedBox(height: 6),
Text(
'Capture wins and signals from everyday interactions.',
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 16),
FrostedCard(
child: Row(
children: <Widget>[
Expanded(
child: TextField(
decoration: InputDecoration(
hintText:
'Quick capture: what happened, how it felt, what to repeat...',
hintStyle: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: AppTheme.textSecondary),
border: InputBorder.none,
),
),
),
FilledButton.icon(
onPressed: () {},
icon: const Icon(Icons.add_rounded),
label: const Text('Add Capture'),
),
],
),
),
const SizedBox(height: 16),
Expanded(
child: ListView.separated(
itemCount: moments.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (BuildContext context, int index) {
final RelationshipMoment moment = moments[index];
final PersonProfile? person = peopleById[moment.personId];
return FrostedCard(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Container(
width: 12,
height: 12,
margin: const EdgeInsets.only(top: 6),
decoration: const BoxDecoration(
color: Color(0xFF1AB6C8),
shape: BoxShape.circle,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
moment.title,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
'${person?.name ?? 'Unknown'}${moment.type.toUpperCase()}',
style: Theme.of(context).textTheme.labelLarge
class _MomentsViewState extends ConsumerState<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>{
for (final PersonProfile person in people) person.id: person,
};
final String? fallbackPersonId = people.isEmpty
? null
: people.first.id;
final String? activePerson =
people.any((PersonProfile p) => p.id == _selectedPersonId)
? _selectedPersonId
: fallbackPersonId;
return Padding(
padding: const EdgeInsets.fromLTRB(28, 24, 28, 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Moments',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'Capture wins and signals from everyday interactions.',
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 16),
FrostedCard(
child: Column(
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: TextField(
controller: _captureController,
decoration: InputDecoration(
hintText:
'Quick capture: what happened, how it felt, what to repeat...',
hintStyle: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: AppTheme.textSecondary),
border: InputBorder.none,
),
const SizedBox(height: 8),
Text(
moment.summary,
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
),
const SizedBox(width: 8),
if (people.isNotEmpty)
DropdownButton<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(
onPressed: people.isEmpty || activePerson == null
? null
: () => _addCapture(activePerson),
icon: const Icon(Icons.add_rounded),
label: const Text('Add Capture'),
),
],
),
if (people.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(
'Add people first before capturing moments.',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: AppTheme.textSecondary),
),
),
const SizedBox(width: 12),
Text(
'${moment.at.month}/${moment.at.day}',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: AppTheme.textSecondary,
),
],
),
),
const SizedBox(height: 16),
Expanded(
child: ListView.separated(
itemCount: moments.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (BuildContext context, int index) {
final RelationshipMoment moment = moments[index];
final PersonProfile? person = peopleById[moment.personId];
return FrostedCard(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Container(
width: 12,
height: 12,
margin: const EdgeInsets.only(top: 6),
decoration: const BoxDecoration(
color: Color(0xFF1AB6C8),
shape: BoxShape.circle,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
moment.title,
style: Theme.of(
context,
).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
'${person?.name ?? 'Unknown'}${moment.type.toUpperCase()}',
style: Theme.of(context).textTheme.labelLarge
?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 8),
Text(
moment.summary,
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Text(
'${moment.at.month}/${moment.at.day}',
style: Theme.of(context).textTheme.labelLarge
?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 8),
IconButton(
onPressed: () {
ref
.read(localRepositoryProvider.notifier)
.deleteMoment(moment.id);
},
icon: const Icon(Icons.delete_outline_rounded),
tooltip: 'Delete capture',
),
],
),
],
),
],
),
);
},
),
);
},
),
),
],
),
],
),
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (Object error, StackTrace stackTrace) {
return const Center(child: Text('Unable to load moments'));
},
);
}
Future<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();
}
}
+394 -92
View File
@@ -12,6 +12,10 @@ class SelectedPersonIdNotifier extends Notifier<String?> {
void select(String id) {
state = id;
}
void clear() {
state = null;
}
}
final NotifierProvider<SelectedPersonIdNotifier, String?>
@@ -24,114 +28,286 @@ class PeopleView extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final List<PersonProfile> people = ref
.watch(localRepositoryProvider)
.people();
final String selectedId =
ref.watch(selectedPersonIdProvider) ?? people.first.id;
final PersonProfile selected = people.firstWhere(
(PersonProfile person) => person.id == selectedId,
orElse: () => people.first,
final AsyncValue<LocalDataState> localData = ref.watch(
localRepositoryProvider,
);
return Padding(
padding: const EdgeInsets.fromLTRB(28, 24, 28, 28),
child: Row(
children: <Widget>[
Expanded(
flex: 5,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'People',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'Track what matters to each relationship and follow through.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 20),
Expanded(
child: ListView.separated(
itemCount: people.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (BuildContext context, int index) {
final PersonProfile person = people[index];
final bool isSelected = person.id == selected.id;
return InkWell(
onTap: () {
ref
.read(selectedPersonIdProvider.notifier)
.select(person.id);
},
borderRadius: BorderRadius.circular(20),
child: FrostedCard(
padding: const EdgeInsets.all(16),
child: Row(
return localData.when(
data: (LocalDataState data) {
final List<PersonProfile> people = data.people;
if (people.isEmpty) {
return _EmptyPeopleView(onAdd: () => _handleAddPerson(context, ref));
}
final String selectedId =
ref.watch(selectedPersonIdProvider) ?? people.first.id;
final PersonProfile selected = people.firstWhere(
(PersonProfile person) => person.id == selectedId,
orElse: () => people.first,
);
return Padding(
padding: const EdgeInsets.fromLTRB(28, 24, 28, 28),
child: Row(
children: <Widget>[
Expanded(
flex: 5,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_AvatarSeed(name: person.name),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
person.name,
style: Theme.of(
context,
).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
person.relationship,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: AppTheme.textSecondary,
),
),
],
),
Text(
'People',
style: Theme.of(
context,
).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'Track what matters to each relationship and follow through.',
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: AppTheme.textSecondary),
),
_ScorePill(score: person.affinityScore),
if (isSelected)
const Padding(
padding: EdgeInsets.only(left: 10),
child: Icon(
Icons.check_circle,
color: Color(0xFF1AB6C8),
),
),
],
),
),
);
},
FilledButton.icon(
onPressed: () => _handleAddPerson(context, ref),
icon: const Icon(Icons.person_add_alt_1_rounded),
label: const Text('Add'),
),
],
),
const SizedBox(height: 20),
Expanded(
child: ListView.separated(
itemCount: people.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (BuildContext context, int index) {
final PersonProfile person = people[index];
final bool isSelected = person.id == selected.id;
return InkWell(
onTap: () {
ref
.read(selectedPersonIdProvider.notifier)
.select(person.id);
},
borderRadius: BorderRadius.circular(20),
child: FrostedCard(
padding: const EdgeInsets.all(16),
child: Row(
children: <Widget>[
_AvatarSeed(name: person.name),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: <Widget>[
Text(
person.name,
style: Theme.of(
context,
).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
person.relationship,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: AppTheme.textSecondary,
),
),
],
),
),
_ScorePill(score: person.affinityScore),
if (isSelected)
const Padding(
padding: EdgeInsets.only(left: 10),
child: Icon(
Icons.check_circle,
color: Color(0xFF1AB6C8),
),
),
],
),
),
);
},
),
),
],
),
),
const SizedBox(width: 18),
Expanded(
flex: 6,
child: FrostedCard(
child: _PersonDetail(
person: selected,
onEdit: () => _handleEditPerson(context, ref, selected),
onDelete: () =>
_handleDeletePerson(context, ref, selected.id),
),
),
],
),
],
),
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (Object error, StackTrace stackTrace) {
return const Center(child: Text('Unable to load people'));
},
);
}
Future<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'),
),
),
const SizedBox(width: 18),
Expanded(
flex: 6,
child: FrostedCard(child: _PersonDetail(person: selected)),
),
],
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Delete'),
),
],
);
},
);
if (confirmed != true) {
return;
}
await ref.read(localRepositoryProvider.notifier).deletePerson(personId);
ref.read(selectedPersonIdProvider.notifier).clear();
}
}
class _EmptyPeopleView extends StatelessWidget {
const _EmptyPeopleView({required this.onAdd});
final VoidCallback onAdd;
@override
Widget build(BuildContext context) {
return Center(
child: FrostedCard(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <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 {
const _PersonDetail({required this.person});
const _PersonDetail({
required this.person,
required this.onEdit,
required this.onDelete,
});
final PersonProfile person;
final VoidCallback onEdit;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
@@ -160,7 +336,16 @@ class _PersonDetail extends StatelessWidget {
],
),
),
_ScorePill(score: person.affinityScore),
IconButton(
onPressed: onEdit,
icon: const Icon(Icons.edit_rounded),
tooltip: 'Edit person',
),
IconButton(
onPressed: onDelete,
icon: const Icon(Icons.delete_outline_rounded),
tooltip: 'Delete person',
),
],
),
const SizedBox(height: 22),
@@ -178,7 +363,7 @@ class _PersonDetail extends StatelessWidget {
runSpacing: 8,
children: person.tags
.map((String tag) => _DetailTag(label: tag))
.toList(),
.toList(growable: false),
),
const SizedBox(height: 18),
Text('Notes', style: Theme.of(context).textTheme.titleMedium),
@@ -194,6 +379,123 @@ class _PersonDetail extends StatelessWidget {
}
}
class _PersonEditorDialog extends StatefulWidget {
const _PersonEditorDialog({required this.title, this.initial});
final String title;
final _PersonDraft? initial;
@override
State<_PersonEditorDialog> createState() => _PersonEditorDialogState();
}
class _PersonEditorDialogState extends State<_PersonEditorDialog> {
late final TextEditingController _nameController;
late final TextEditingController _relationshipController;
late final TextEditingController _tagsController;
late final TextEditingController _notesController;
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.initial?.name ?? '');
_relationshipController = TextEditingController(
text: widget.initial?.relationship ?? '',
);
_tagsController = TextEditingController(
text: widget.initial?.tags.join(', ') ?? '',
);
_notesController = TextEditingController(text: widget.initial?.notes ?? '');
}
@override
void dispose() {
_nameController.dispose();
_relationshipController.dispose();
_tagsController.dispose();
_notesController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.title),
content: SingleChildScrollView(
child: SizedBox(
width: 420,
child: Column(
mainAxisSize: MainAxisSize.min,
children: <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 {
const _DetailTag({required this.label});
+21 -6
View File
@@ -40,9 +40,24 @@ class SignalsController extends AsyncNotifier<SignalsFeed> {
try {
return await ref.read(backendGatewayProvider).getSignalsFeed(limit: 20);
} catch (_) {
final List<PersonProfile> people = ref
.read(localRepositoryProvider)
.people();
final LocalDataState localData = await ref.read(
localRepositoryProvider.future,
);
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();
return SignalsFeed(
cursor: 'fallback-signals',
@@ -50,9 +65,9 @@ class SignalsController extends AsyncNotifier<SignalsFeed> {
SignalItem(
id: 'fallback-1',
type: 'recommendation',
title: 'Check in with ${people.first.name} tonight',
title: 'Check in with ${defaultPerson.name} tonight',
description: 'A short voice memo can keep momentum.',
personId: people.first.id,
personId: defaultPerson.id,
createdAt: now,
),
SignalItem(
@@ -60,7 +75,7 @@ class SignalsController extends AsyncNotifier<SignalsFeed> {
type: 'trend',
title: 'Gift idea: practical + personal is trending',
description: 'Combine utility with one sentimental detail.',
personId: people.last.id,
personId: secondaryPerson.id,
createdAt: now.subtract(const Duration(hours: 2)),
),
],