feat: add local-first private AI digest workflow

Migrate app code into canonical feature slices, add phone-only AI digest scheduling and review, wire local notification/background task support, and cover the flow with tests.
This commit is contained in:
Rijad Zuzo
2026-05-17 00:17:20 +02:00
parent dab50abf0e
commit f655adfbea
212 changed files with 24178 additions and 15895 deletions
+13
View File
@@ -0,0 +1,13 @@
# Feature Slices
Each feature folder is the main place to work on product behavior.
Typical subfolders:
- `domain/`: models or invariants owned by the slice
- `application/`: orchestration, providers, controllers, side effects
- `presentation/`: screens, widgets, dialogs, UI state
- `data/`: persistence or repositories owned by the slice
Some older files at the slice root still exist as compatibility exports so
tests and untouched code paths keep working while the structure settles.
@@ -0,0 +1,105 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
const String aiDigestNotificationPayload = 'ai_digest_review_inbox';
final Provider<AiDigestNotificationIntentBus>
aiDigestNotificationIntentBusProvider = Provider<AiDigestNotificationIntentBus>(
(Ref ref) {
final AiDigestNotificationIntentBus bus = AiDigestNotificationIntentBus();
ref.onDispose(() {
unawaited(bus.close());
});
return bus;
},
);
class AiDigestNotificationIntentBus {
final StreamController<String> _controller =
StreamController<String>.broadcast();
Stream<String> get intents => _controller.stream;
void emitTap(String payload) {
if (!_controller.isClosed) {
_controller.add(payload);
}
}
Future<void> close() => _controller.close();
}
abstract interface class AiDigestNotifier {
Future<void> showDigestReady(int count);
}
class LocalAiDigestNotifier implements AiDigestNotifier {
LocalAiDigestNotifier({
FlutterLocalNotificationsPlugin? plugin,
void Function(String payload)? onTapPayload,
}) : _plugin = plugin ?? FlutterLocalNotificationsPlugin(),
_onTapPayload = onTapPayload;
final FlutterLocalNotificationsPlugin _plugin;
final void Function(String payload)? _onTapPayload;
Future<void>? _initializeFuture;
@override
Future<void> showDigestReady(int count) async {
if (!_supportsNotifications()) {
return;
}
await _ensureInitialized();
await _plugin.show(
id: 8124,
title: 'Relationship digest ready',
body:
'$count private ${count == 1 ? 'suggestion is' : 'suggestions are'} ready to review.',
notificationDetails: const NotificationDetails(
android: AndroidNotificationDetails(
'relationship_saver_ai_digest',
'Relationship Digest',
channelDescription: 'Private AI digest suggestions ready for review',
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
),
iOS: DarwinNotificationDetails(),
macOS: DarwinNotificationDetails(),
),
payload: aiDigestNotificationPayload,
);
}
Future<void> _ensureInitialized() {
return _initializeFuture ??= _plugin.initialize(
settings: const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
iOS: DarwinInitializationSettings(),
macOS: DarwinInitializationSettings(),
),
onDidReceiveNotificationResponse: (NotificationResponse response) {
final String? payload = response.payload;
if (payload != null && payload.isNotEmpty) {
_onTapPayload?.call(payload);
}
},
);
}
bool _supportsNotifications() {
if (kIsWeb) {
return false;
}
return defaultTargetPlatform != TargetPlatform.linux;
}
}
final Provider<AiDigestNotifier> aiDigestNotifierProvider =
Provider<AiDigestNotifier>((Ref ref) {
return LocalAiDigestNotifier(
onTapPayload: ref.watch(aiDigestNotificationIntentBusProvider).emitTap,
);
});
@@ -0,0 +1,175 @@
import 'dart:convert';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:uuid/uuid.dart';
const Uuid _uuid = Uuid();
class AiDigestParseResult {
const AiDigestParseResult({required this.drafts, this.error});
final List<AiSuggestionDraft> drafts;
final String? error;
bool get success => error == null;
}
class AiDigestResponseParser {
const AiDigestResponseParser({
this.maxItems = 10,
this.maxTitleChars = 90,
this.maxDetailsChars = 420,
});
final int maxItems;
final int maxTitleChars;
final int maxDetailsChars;
AiDigestParseResult parse({
required String response,
required Map<String, String> tokenToPersonId,
required String sourceRunId,
required DateTime suggestedAt,
}) {
try {
final Object? decoded = jsonDecode(_extractJson(response));
final List<dynamic> items = _extractItems(decoded);
final List<AiSuggestionDraft> drafts = <AiSuggestionDraft>[];
for (final dynamic item in items.take(maxItems)) {
if (item is! Map<String, dynamic>) {
continue;
}
final String? token = _string(item['personToken']);
final String? personId = token == null ? null : tokenToPersonId[token];
if (personId == null) {
continue;
}
final AiSuggestionKind? kind = _kind(item['kind']);
final String title = _trimAndCap(
_string(item['title']) ?? '',
maxTitleChars,
);
if (kind == null || title.isEmpty) {
continue;
}
drafts.add(
AiSuggestionDraft(
id: 'ai-${_uuid.v4()}',
personId: personId,
kind: kind,
title: title,
details: _trimAndCap(
_string(item['details']) ?? '',
maxDetailsChars,
),
suggestedAt: suggestedAt,
suggestedFor:
_dateOrNull(item['suggestedFor']) ??
_dateOrNull(item['suggestedTiming']),
confidence: _confidence(item['confidence']),
status: AiSuggestionStatus.pending,
sourceRunId: sourceRunId,
reason: _trimAndCap(_string(item['reason']) ?? '', 240),
),
);
}
if (drafts.isEmpty) {
return const AiDigestParseResult(
drafts: <AiSuggestionDraft>[],
error: 'No valid digest suggestions returned.',
);
}
return AiDigestParseResult(drafts: drafts);
} catch (error) {
return AiDigestParseResult(
drafts: const <AiSuggestionDraft>[],
error: 'Unable to parse digest response: $error',
);
}
}
String _extractJson(String response) {
final String trimmed = response.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
return trimmed;
}
final int objectStart = trimmed.indexOf('{');
final int arrayStart = trimmed.indexOf('[');
final int start = objectStart == -1
? arrayStart
: arrayStart == -1
? objectStart
: objectStart < arrayStart
? objectStart
: arrayStart;
final int objectEnd = trimmed.lastIndexOf('}');
final int arrayEnd = trimmed.lastIndexOf(']');
final int end = objectEnd > arrayEnd ? objectEnd : arrayEnd;
if (start < 0 || end < start) {
throw const FormatException('No JSON object or array found.');
}
return trimmed.substring(start, end + 1);
}
List<dynamic> _extractItems(Object? decoded) {
if (decoded is List<dynamic>) {
return decoded;
}
if (decoded is Map<String, dynamic>) {
final Object? suggestions = decoded['suggestions'] ?? decoded['items'];
if (suggestions is List<dynamic>) {
return suggestions;
}
}
throw const FormatException('Digest response must be a JSON array.');
}
AiSuggestionKind? _kind(Object? raw) {
final String? value = _string(raw);
if (value == null) {
return null;
}
for (final AiSuggestionKind kind in AiSuggestionKind.values) {
if (kind.name == value) {
return kind;
}
}
return null;
}
String? _string(Object? raw) {
if (raw == null) {
return null;
}
final String value = '$raw'.trim();
return value.isEmpty ? null : value;
}
DateTime? _dateOrNull(Object? raw) {
final String? value = _string(raw);
if (value == null) {
return null;
}
return DateTime.tryParse(value)?.toLocal();
}
double _confidence(Object? raw) {
if (raw is num) {
return raw.toDouble().clamp(0.0, 1.0).toDouble();
}
return 0.5;
}
String _trimAndCap(String value, int maxChars) {
final String trimmed = value.trim();
if (trimmed.length <= maxChars) {
return trimmed;
}
return trimmed.substring(0, maxChars).trim();
}
}
@@ -0,0 +1,285 @@
import 'dart:convert';
import 'dart:math' as math;
import 'package:relationship_saver/app/state/local_data_state.dart';
import 'package:relationship_saver/features/people/domain/person_models.dart';
class AnonymizedLlmDigestContext {
const AnonymizedLlmDigestContext({
required this.payload,
required this.tokenToPersonId,
});
final Map<String, dynamic> payload;
final Map<String, String> tokenToPersonId;
String toPromptJson() {
return const JsonEncoder.withIndent(' ').convert(payload);
}
}
class AnonymizedLlmContextBuilder {
const AnonymizedLlmContextBuilder({
this.maxPeople = 20,
this.maxSignalsPerPerson = 8,
DateTime Function()? now,
}) : _now = now ?? DateTime.now;
final int maxPeople;
final int maxSignalsPerPerson;
final DateTime Function() _now;
AnonymizedLlmDigestContext build(LocalDataState state) {
final DateTime now = _now();
final List<PersonProfile> selectedPeople =
state.people
.where(
(PersonProfile person) => _isDigestRelevant(state, person, now),
)
.toList(growable: false)
..sort(
(PersonProfile a, PersonProfile b) => _urgencyScore(
state,
b,
now,
).compareTo(_urgencyScore(state, a, now)),
);
final List<Map<String, dynamic>> peoplePayload = <Map<String, dynamic>>[];
final Map<String, String> tokenToPersonId = <String, String>{};
for (int i = 0; i < math.min(selectedPeople.length, maxPeople); i += 1) {
final PersonProfile person = selectedPeople[i];
final String token = 'person_${(i + 1).toString().padLeft(3, '0')}';
tokenToPersonId[token] = person.id;
peoplePayload.add(_personPayload(state, person, token, now));
}
return AnonymizedLlmDigestContext(
tokenToPersonId: tokenToPersonId,
payload: <String, dynamic>{
'schema': 'relationship_saver_private_digest_v1',
'task':
'Create a balanced private weekly digest with gift ideas, event ideas, reminders, and check-ins.',
'rules': <String>[
'Use only personToken values from the input.',
'Do not infer or ask for names.',
'Return JSON only.',
'Prefer practical suggestions that can be reviewed before saving.',
],
'limits': <String, dynamic>{
'maxItems': 10,
'allowedKinds': <String>[
'giftIdea',
'eventIdea',
'checkIn',
'reminder',
],
},
'people': peoplePayload,
},
);
}
bool _isDigestRelevant(
LocalDataState state,
PersonProfile person,
DateTime now,
) {
if (_daysUntil(person.nextMoment, now).abs() <= 60) {
return true;
}
final DateTime? last = person.lastInteractedAt;
if (last == null || now.difference(last).inDays >= 14) {
return true;
}
return state.importantDates.any(
(PersonImportantDate value) =>
value.personId == person.id &&
!value.isSensitive &&
_daysUntil(value.date, now).abs() <= 60,
) ||
state.preferenceSignals.any(
(PersonPreferenceSignal signal) =>
signal.personId == person.id &&
signal.status != PreferenceSignalStatus.dismissed,
);
}
int _urgencyScore(LocalDataState state, PersonProfile person, DateTime now) {
final int nextMomentDays = _daysUntil(person.nextMoment, now).abs();
int score = math.max(0, 80 - nextMomentDays);
final DateTime? last = person.lastInteractedAt;
if (last == null) {
score += 30;
} else {
score += math.min(40, now.difference(last).inDays);
}
score +=
state.importantDates
.where(
(PersonImportantDate value) =>
value.personId == person.id &&
!value.isSensitive &&
_daysUntil(value.date, now).abs() <= 60,
)
.length *
10;
return score;
}
Map<String, dynamic> _personPayload(
LocalDataState state,
PersonProfile person,
String token,
DateTime now,
) {
final List<PersonPreferenceSignal> signals = state.preferenceSignals
.where(
(PersonPreferenceSignal signal) =>
signal.personId == person.id &&
signal.status != PreferenceSignalStatus.dismissed,
)
.take(maxSignalsPerPerson)
.toList(growable: false);
final List<PersonImportantDate> dates = state.importantDates
.where(
(PersonImportantDate value) =>
value.personId == person.id && !value.isSensitive,
)
.take(maxSignalsPerPerson)
.toList(growable: false);
return <String, dynamic>{
'personToken': token,
'relationshipCategory': _relationshipCategory(person.relationship),
'affinityBand': _affinityBand(person.affinityScore),
'upcoming': <String>[
_relativeWindow('next planned moment', person.nextMoment, now),
...dates.map(
(PersonImportantDate value) => _relativeWindow(
_safeCategory(value.classification),
value.date,
now,
),
),
],
'recency': _recency(person.lastInteractedAt, now),
'interests': _safeList(<String>[
...person.tags,
...signals
.where(
(PersonPreferenceSignal signal) =>
signal.polarity == PreferenceSignalPolarity.like,
)
.map((PersonPreferenceSignal signal) => signal.label),
]).take(maxSignalsPerPerson).toList(growable: false),
'constraints': _safeList(
signals
.where(
(PersonPreferenceSignal signal) =>
signal.polarity == PreferenceSignalPolarity.dislike,
)
.map((PersonPreferenceSignal signal) => 'avoid ${signal.label}')
.toList(growable: false),
).take(maxSignalsPerPerson).toList(growable: false),
};
}
String _relationshipCategory(String value) {
final String normalized = value.toLowerCase();
if (_containsAny(normalized, <String>[
'partner',
'spouse',
'wife',
'husband',
])) {
return 'partner';
}
if (_containsAny(normalized, <String>[
'family',
'sister',
'brother',
'mother',
'father',
'parent',
'cousin',
'aunt',
'uncle',
])) {
return 'family';
}
if (normalized.contains('friend')) {
return 'friend';
}
if (_containsAny(normalized, <String>['work', 'colleague', 'coworker'])) {
return 'colleague';
}
return 'relationship';
}
String _affinityBand(int score) {
if (score >= 85) {
return 'very close';
}
if (score >= 65) {
return 'close';
}
return 'light';
}
String _relativeWindow(String label, DateTime date, DateTime now) {
final int days = _daysUntil(date, now);
final String when = days == 0
? 'today'
: days > 0
? 'in about $days days'
: 'about ${days.abs()} days ago';
return '${_safeCategory(label)} $when';
}
String _recency(DateTime? lastInteractedAt, DateTime now) {
if (lastInteractedAt == null) {
return 'no recent interaction recorded';
}
final int days = now.difference(lastInteractedAt).inDays;
if (days <= 1) {
return 'contacted recently';
}
return 'last contact about $days days ago';
}
List<String> _safeList(Iterable<String> values) {
final List<String> out = <String>[];
final Set<String> seen = <String>{};
for (final String raw in values) {
final String value = _safeCategory(raw);
if (value.isEmpty) {
continue;
}
if (seen.add(value.toLowerCase())) {
out.add(value);
}
}
return out;
}
String _safeCategory(String value) {
return value
.trim()
.replaceAll(RegExp(r'https?://\S+'), '')
.replaceAll(RegExp(r'[^a-zA-Z0-9 +&/-]+'), '')
.replaceAll(RegExp(r'\s+'), ' ')
.trim()
.toLowerCase();
}
int _daysUntil(DateTime date, DateTime now) {
return date.difference(DateTime(now.year, now.month, now.day)).inDays;
}
bool _containsAny(String value, List<String> probes) {
return probes.any(value.contains);
}
}
@@ -0,0 +1,125 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/features/ai_digest/data/llm_digest_config.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:workmanager/workmanager.dart';
const String llmDigestTaskUniqueName = 'relationship_saver_llm_digest_weekly';
const String llmDigestTaskName = 'com.relationshipsaver.llm.digest';
abstract interface class LlmDigestBackgroundScheduler {
Future<void> configure(LlmDigestConfigState config);
Future<void> cancel();
}
class WorkmanagerLlmDigestBackgroundScheduler
implements LlmDigestBackgroundScheduler {
const WorkmanagerLlmDigestBackgroundScheduler({Workmanager? workmanager})
: _workmanager = workmanager;
final Workmanager? _workmanager;
Workmanager get _driver => _workmanager ?? Workmanager();
@override
Future<void> configure(LlmDigestConfigState config) async {
if (!config.enabled) {
await cancel();
return;
}
try {
await _driver.registerPeriodicTask(
llmDigestTaskUniqueName,
llmDigestTaskName,
frequency: const Duration(days: 7),
initialDelay: _initialDelay(config, DateTime.now()),
constraints: Constraints(
networkType: config.requireWifi
? NetworkType.unmetered
: NetworkType.connected,
requiresCharging: config.requireCharging,
),
existingWorkPolicy: ExistingPeriodicWorkPolicy.update,
);
} on UnimplementedError {
// Flutter tests and unsupported platforms have no Workmanager backend.
}
}
@override
Future<void> cancel() async {
try {
await _driver.cancelByUniqueName(llmDigestTaskUniqueName);
} on UnimplementedError {
// Flutter tests and unsupported platforms have no Workmanager backend.
}
}
Duration _initialDelay(LlmDigestConfigState config, DateTime now) {
DateTime target = DateTime(
now.year,
now.month,
now.day,
config.preferredHour,
);
final int dayDelta = (config.preferredWeekday - now.weekday) % 7;
target = target.add(Duration(days: dayDelta));
if (!target.isAfter(now)) {
target = target.add(const Duration(days: 7));
}
return target.difference(now);
}
}
final Provider<LlmDigestBackgroundScheduler>
llmDigestBackgroundSchedulerProvider = Provider<LlmDigestBackgroundScheduler>(
(Ref ref) => const WorkmanagerLlmDigestBackgroundScheduler(),
);
class LlmDigestBackgroundRunner extends ConsumerStatefulWidget {
const LlmDigestBackgroundRunner({required this.child, super.key});
final Widget child;
@override
ConsumerState<LlmDigestBackgroundRunner> createState() =>
_LlmDigestBackgroundRunnerState();
}
class _LlmDigestBackgroundRunnerState
extends ConsumerState<LlmDigestBackgroundRunner> {
ProviderSubscription<LlmDigestConfigState>? _subscription;
@override
void initState() {
super.initState();
unawaited(_initializeAndConfigure());
_subscription = ref.listenManual<LlmDigestConfigState>(
llmDigestConfigProvider,
(LlmDigestConfigState? previous, LlmDigestConfigState next) {
unawaited(
ref.read(llmDigestBackgroundSchedulerProvider).configure(next),
);
},
);
}
Future<void> _initializeAndConfigure() async {
await ref.read(llmDigestConfigProvider.notifier).initialize();
await ref
.read(llmDigestBackgroundSchedulerProvider)
.configure(ref.read(llmDigestConfigProvider));
}
@override
void dispose() {
_subscription?.close();
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
}
@@ -0,0 +1,40 @@
import 'package:battery_plus/battery_plus.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
abstract interface class LlmDigestEnvironment {
Future<bool> canRun(LlmDigestConfigState config);
}
class DeviceLlmDigestEnvironment implements LlmDigestEnvironment {
DeviceLlmDigestEnvironment({Connectivity? connectivity, Battery? battery})
: _connectivity = connectivity ?? Connectivity(),
_battery = battery ?? Battery();
final Connectivity _connectivity;
final Battery _battery;
@override
Future<bool> canRun(LlmDigestConfigState config) async {
if (config.requireWifi) {
final List<ConnectivityResult> results = await _connectivity
.checkConnectivity();
if (!results.contains(ConnectivityResult.wifi)) {
return false;
}
}
if (config.requireCharging) {
final BatteryState state = await _battery.batteryState;
if (state != BatteryState.charging && state != BatteryState.full) {
return false;
}
}
return true;
}
}
final Provider<LlmDigestEnvironment> llmDigestEnvironmentProvider =
Provider<LlmDigestEnvironment>((Ref ref) => DeviceLlmDigestEnvironment());
@@ -0,0 +1,256 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/app/data/relationship_repository.dart';
import 'package:relationship_saver/app/state/local_data_state.dart';
import 'package:relationship_saver/core/llm/llm_config.dart';
import 'package:relationship_saver/features/ai_digest/application/ai_digest_notifier.dart';
import 'package:relationship_saver/features/ai_digest/application/ai_digest_response_parser.dart';
import 'package:relationship_saver/features/ai_digest/application/anonymized_llm_context_builder.dart';
import 'package:relationship_saver/features/ai_digest/application/llm_digest_environment.dart';
import 'package:relationship_saver/features/ai_digest/application/llm_digest_text_client.dart';
import 'package:relationship_saver/features/ai_digest/data/llm_digest_config.dart';
import 'package:relationship_saver/features/ai_digest/data/llm_digest_run_store.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:uuid/uuid.dart';
const Duration _scheduledCooldown = Duration(days: 7);
const Duration _staleRunningAfter = Duration(minutes: 30);
const Uuid _uuid = Uuid();
final Provider<bool> llmDigestApiKeyConfiguredProvider = Provider<bool>(
(Ref ref) => ref.watch(llmConfigProvider).isConfigured,
);
final Provider<Future<void> Function()> llmDigestInitializerProvider =
Provider<Future<void> Function()>((Ref ref) {
return () async {
await ref.read(llmConfigProvider.notifier).initialize();
await ref.read(llmDigestConfigProvider.notifier).initialize();
};
});
class LlmDigestRunResult {
const LlmDigestRunResult({
required this.started,
required this.completed,
this.createdDraftCount = 0,
this.reason,
});
final bool started;
final bool completed;
final int createdDraftCount;
final String? reason;
}
class LlmDigestOrchestrator {
LlmDigestOrchestrator(this._ref, {DateTime Function()? now})
: _now = now ?? DateTime.now;
final Ref _ref;
final DateTime Function() _now;
Future<LlmDigestRunResult> runScheduledDigest() {
return _run(bypassCooldown: false, notify: true);
}
Future<LlmDigestRunResult> runManualDigest() {
return _run(bypassCooldown: true, notify: true);
}
Future<LlmDigestRunResult> _run({
required bool bypassCooldown,
required bool notify,
}) async {
await _ref.read(llmDigestInitializerProvider)();
final LlmDigestConfigState config = _ref.read(llmDigestConfigProvider);
if (!config.enabled && !bypassCooldown) {
return const LlmDigestRunResult(
started: false,
completed: false,
reason: 'Digest is disabled.',
);
}
if (!_ref.read(llmDigestApiKeyConfiguredProvider)) {
await _markFailure('No LLM API key configured.');
return const LlmDigestRunResult(
started: false,
completed: false,
reason: 'No LLM API key configured.',
);
}
final LlmDigestRunStore store = _ref.read(llmDigestRunStoreProvider);
final LlmDigestRunState before = await store.read();
final DateTime now = _now();
if (_isAlreadyRunning(before, now)) {
return const LlmDigestRunResult(
started: false,
completed: false,
reason: 'Digest is already running.',
);
}
if (!bypassCooldown && !_cooldownAllowsRun(before, now)) {
return const LlmDigestRunResult(
started: false,
completed: false,
reason: 'Weekly digest cooldown is still active.',
);
}
if (!bypassCooldown && !_failureBackoffAllowsRun(before, now)) {
return const LlmDigestRunResult(
started: false,
completed: false,
reason: 'Failure backoff is still active.',
);
}
if (!await _ref.read(llmDigestEnvironmentProvider).canRun(config)) {
return const LlmDigestRunResult(
started: false,
completed: false,
reason: 'Network or charging policy is not satisfied.',
);
}
await store.write(
before.copyWith(lastStartedAt: now, running: true, lastFailure: null),
);
try {
final LocalDataState state = await _ref.read(
localRepositoryProvider.future,
);
final AnonymizedLlmDigestContext context =
const AnonymizedLlmContextBuilder().build(state);
if (context.tokenToPersonId.isEmpty) {
await store.write(
before.copyWith(
lastStartedAt: now,
lastCompletedAt: now,
running: false,
lastFailure: null,
consecutiveFailures: 0,
),
);
return const LlmDigestRunResult(
started: true,
completed: true,
reason: 'No eligible people for digest.',
);
}
final String sourceRunId = 'digest-${_uuid.v4()}';
final String response = await _ref
.read(llmDigestTextClientProvider)
.complete(
systemPrompt: _systemPrompt,
userPrompt: context.toPromptJson(),
);
final AiDigestParseResult parsed = const AiDigestResponseParser().parse(
response: response,
tokenToPersonId: context.tokenToPersonId,
sourceRunId: sourceRunId,
suggestedAt: now,
);
if (!parsed.success) {
await _markFailure(parsed.error ?? 'Invalid digest response.');
return LlmDigestRunResult(
started: true,
completed: false,
reason: parsed.error,
);
}
await _ref
.read(localRepositoryProvider.notifier)
.saveAiSuggestionDrafts(parsed.drafts);
await store.write(
LlmDigestRunState(
lastStartedAt: now,
lastCompletedAt: _now(),
consecutiveFailures: 0,
),
);
if (notify && parsed.drafts.isNotEmpty) {
await _ref
.read(aiDigestNotifierProvider)
.showDigestReady(parsed.drafts.length);
}
return LlmDigestRunResult(
started: true,
completed: true,
createdDraftCount: parsed.drafts.length,
);
} catch (error) {
await _markFailure('$error');
return LlmDigestRunResult(
started: true,
completed: false,
reason: '$error',
);
}
}
Future<void> _markFailure(String reason) async {
final LlmDigestRunStore store = _ref.read(llmDigestRunStoreProvider);
final LlmDigestRunState current = await store.read();
await store.write(
LlmDigestRunState(
lastStartedAt: current.lastStartedAt,
lastCompletedAt: current.lastCompletedAt,
lastFailure: reason,
consecutiveFailures: current.consecutiveFailures + 1,
),
);
}
bool _isAlreadyRunning(LlmDigestRunState state, DateTime now) {
final DateTime? started = state.lastStartedAt;
return state.running &&
started != null &&
now.difference(started) < _staleRunningAfter;
}
bool _cooldownAllowsRun(LlmDigestRunState state, DateTime now) {
final DateTime? completed = state.lastCompletedAt;
return completed == null || now.difference(completed) >= _scheduledCooldown;
}
bool _failureBackoffAllowsRun(LlmDigestRunState state, DateTime now) {
if (state.consecutiveFailures <= 0) {
return true;
}
final DateTime? started = state.lastStartedAt;
if (started == null) {
return true;
}
final int multiplier = 1 << state.consecutiveFailures.clamp(0, 5).toInt();
return now.difference(started) >= Duration(hours: multiplier);
}
}
const String _systemPrompt = '''
You create private relationship-maintenance digest suggestions.
The input contains pseudonymous people only. Never ask for or invent names.
Return exactly one JSON object:
{
"suggestions": [
{
"personToken": "person_001",
"kind": "giftIdea" | "eventIdea" | "checkIn" | "reminder",
"title": "short action title",
"details": "practical details for the user to review",
"suggestedTiming": "optional ISO-8601 date or null",
"confidence": 0.0,
"reason": "brief non-sensitive rationale"
}
]
}
Return at most 10 suggestions. Use only personToken values present in input.
''';
final Provider<LlmDigestOrchestrator> llmDigestOrchestratorProvider =
Provider<LlmDigestOrchestrator>((Ref ref) => LlmDigestOrchestrator(ref));
@@ -0,0 +1,31 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/llm/llm_service.dart';
abstract interface class LlmDigestTextClient {
Future<String> complete({
required String systemPrompt,
required String userPrompt,
});
}
class ConfiguredLlmDigestTextClient implements LlmDigestTextClient {
const ConfiguredLlmDigestTextClient(this._service);
final LlmService _service;
@override
Future<String> complete({
required String systemPrompt,
required String userPrompt,
}) {
return _service.completeText(
systemPrompt: systemPrompt,
userPrompt: userPrompt,
);
}
}
final Provider<LlmDigestTextClient> llmDigestTextClientProvider =
Provider<LlmDigestTextClient>((Ref ref) {
return ConfiguredLlmDigestTextClient(ref.watch(llmServiceProvider));
});
@@ -0,0 +1,77 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:shared_preferences/shared_preferences.dart';
const String _enabledKey = 'llm_digest_enabled';
const String _preferredWeekdayKey = 'llm_digest_preferred_weekday';
const String _preferredHourKey = 'llm_digest_preferred_hour';
const String _requireWifiKey = 'llm_digest_require_wifi';
const String _requireChargingKey = 'llm_digest_require_charging';
class LlmDigestConfigNotifier extends Notifier<LlmDigestConfigState> {
Future<void>? _initializeFuture;
@override
LlmDigestConfigState build() {
return const LlmDigestConfigState();
}
Future<void> initialize() {
_initializeFuture ??= _initializeFromStorage();
return _initializeFuture!;
}
Future<void> _initializeFromStorage() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
state = LlmDigestConfigState(
enabled: prefs.getBool(_enabledKey) ?? false,
preferredWeekday: prefs.getInt(_preferredWeekdayKey) ?? DateTime.sunday,
preferredHour: prefs.getInt(_preferredHourKey) ?? 21,
requireWifi: prefs.getBool(_requireWifiKey) ?? true,
requireCharging: prefs.getBool(_requireChargingKey) ?? true,
);
}
Future<void> setEnabled(bool enabled) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setBool(_enabledKey, enabled);
state = state.copyWith(enabled: enabled);
}
Future<void> setPreferredRunWindow({
required int weekday,
required int hour,
}) async {
final int normalizedWeekday = weekday.clamp(
DateTime.monday,
DateTime.sunday,
);
final int normalizedHour = hour.clamp(0, 23);
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setInt(_preferredWeekdayKey, normalizedWeekday);
await prefs.setInt(_preferredHourKey, normalizedHour);
state = state.copyWith(
preferredWeekday: normalizedWeekday,
preferredHour: normalizedHour,
);
}
Future<void> setNetworkPolicy({
required bool requireWifi,
required bool requireCharging,
}) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setBool(_requireWifiKey, requireWifi);
await prefs.setBool(_requireChargingKey, requireCharging);
state = state.copyWith(
requireWifi: requireWifi,
requireCharging: requireCharging,
);
}
}
final NotifierProvider<LlmDigestConfigNotifier, LlmDigestConfigState>
llmDigestConfigProvider =
NotifierProvider<LlmDigestConfigNotifier, LlmDigestConfigState>(
LlmDigestConfigNotifier.new,
);
@@ -0,0 +1,34 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:shared_preferences/shared_preferences.dart';
const String _runStateKey = 'llm_digest_run_state';
class LlmDigestRunStore {
const LlmDigestRunStore();
Future<LlmDigestRunState> read() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
final String? raw = prefs.getString(_runStateKey);
if (raw == null || raw.isEmpty) {
return const LlmDigestRunState();
}
try {
return LlmDigestRunState.fromJson(
jsonDecode(raw) as Map<String, dynamic>,
);
} on FormatException {
return const LlmDigestRunState();
}
}
Future<void> write(LlmDigestRunState state) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setString(_runStateKey, jsonEncode(state.toJson()));
}
}
final Provider<LlmDigestRunStore> llmDigestRunStoreProvider =
Provider<LlmDigestRunStore>((Ref ref) => const LlmDigestRunStore());
@@ -0,0 +1,207 @@
// ignore_for_file: sort_constructors_first
import 'package:flutter/foundation.dart';
enum AiSuggestionKind { giftIdea, eventIdea, checkIn, reminder }
enum AiSuggestionStatus { pending, accepted, dismissed }
enum LlmDigestCadence { weekly }
@immutable
class AiSuggestionDraft {
const AiSuggestionDraft({
required this.id,
required this.personId,
required this.kind,
required this.title,
required this.details,
required this.suggestedAt,
required this.confidence,
required this.status,
required this.sourceRunId,
this.suggestedFor,
this.reason,
});
final String id;
final String personId;
final AiSuggestionKind kind;
final String title;
final String details;
final DateTime suggestedAt;
final DateTime? suggestedFor;
final double confidence;
final AiSuggestionStatus status;
final String sourceRunId;
final String? reason;
AiSuggestionDraft copyWith({
String? id,
String? personId,
AiSuggestionKind? kind,
String? title,
String? details,
DateTime? suggestedAt,
DateTime? suggestedFor,
double? confidence,
AiSuggestionStatus? status,
String? sourceRunId,
String? reason,
}) {
return AiSuggestionDraft(
id: id ?? this.id,
personId: personId ?? this.personId,
kind: kind ?? this.kind,
title: title ?? this.title,
details: details ?? this.details,
suggestedAt: suggestedAt ?? this.suggestedAt,
suggestedFor: suggestedFor ?? this.suggestedFor,
confidence: confidence ?? this.confidence,
status: status ?? this.status,
sourceRunId: sourceRunId ?? this.sourceRunId,
reason: reason ?? this.reason,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'personId': personId,
'kind': kind.name,
'title': title,
'details': details,
'suggestedAt': suggestedAt.toUtc().toIso8601String(),
'suggestedFor': suggestedFor?.toUtc().toIso8601String(),
'confidence': confidence,
'status': status.name,
'sourceRunId': sourceRunId,
'reason': reason,
};
}
factory AiSuggestionDraft.fromJson(Map<String, dynamic> json) {
final String kindName =
json['kind'] as String? ?? AiSuggestionKind.checkIn.name;
final String statusName =
json['status'] as String? ?? AiSuggestionStatus.pending.name;
return AiSuggestionDraft(
id: json['id'] as String,
personId: json['personId'] as String,
kind: AiSuggestionKind.values.firstWhere(
(AiSuggestionKind value) => value.name == kindName,
orElse: () => AiSuggestionKind.checkIn,
),
title: json['title'] as String? ?? 'Suggestion',
details: json['details'] as String? ?? '',
suggestedAt: DateTime.parse(
json['suggestedAt'] as String? ??
DateTime.now().toUtc().toIso8601String(),
).toLocal(),
suggestedFor: json['suggestedFor'] == null
? null
: DateTime.parse(json['suggestedFor'] as String).toLocal(),
confidence: (json['confidence'] as num?)?.toDouble() ?? 0,
status: AiSuggestionStatus.values.firstWhere(
(AiSuggestionStatus value) => value.name == statusName,
orElse: () => AiSuggestionStatus.pending,
),
sourceRunId: json['sourceRunId'] as String? ?? 'unknown-run',
reason: json['reason'] as String?,
);
}
}
@immutable
class LlmDigestRunState {
const LlmDigestRunState({
this.lastStartedAt,
this.lastCompletedAt,
this.lastFailure,
this.consecutiveFailures = 0,
this.running = false,
});
final DateTime? lastStartedAt;
final DateTime? lastCompletedAt;
final String? lastFailure;
final int consecutiveFailures;
final bool running;
LlmDigestRunState copyWith({
DateTime? lastStartedAt,
DateTime? lastCompletedAt,
String? lastFailure,
int? consecutiveFailures,
bool? running,
}) {
return LlmDigestRunState(
lastStartedAt: lastStartedAt ?? this.lastStartedAt,
lastCompletedAt: lastCompletedAt ?? this.lastCompletedAt,
lastFailure: lastFailure,
consecutiveFailures: consecutiveFailures ?? this.consecutiveFailures,
running: running ?? this.running,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'lastStartedAt': lastStartedAt?.toUtc().toIso8601String(),
'lastCompletedAt': lastCompletedAt?.toUtc().toIso8601String(),
'lastFailure': lastFailure,
'consecutiveFailures': consecutiveFailures,
'running': running,
};
}
factory LlmDigestRunState.fromJson(Map<String, dynamic> json) {
return LlmDigestRunState(
lastStartedAt: json['lastStartedAt'] == null
? null
: DateTime.parse(json['lastStartedAt'] as String).toLocal(),
lastCompletedAt: json['lastCompletedAt'] == null
? null
: DateTime.parse(json['lastCompletedAt'] as String).toLocal(),
lastFailure: json['lastFailure'] as String?,
consecutiveFailures: (json['consecutiveFailures'] as num?)?.toInt() ?? 0,
running: json['running'] as bool? ?? false,
);
}
}
@immutable
class LlmDigestConfigState {
const LlmDigestConfigState({
this.enabled = false,
this.cadence = LlmDigestCadence.weekly,
this.preferredWeekday = DateTime.sunday,
this.preferredHour = 21,
this.requireWifi = true,
this.requireCharging = true,
});
final bool enabled;
final LlmDigestCadence cadence;
final int preferredWeekday;
final int preferredHour;
final bool requireWifi;
final bool requireCharging;
LlmDigestConfigState copyWith({
bool? enabled,
LlmDigestCadence? cadence,
int? preferredWeekday,
int? preferredHour,
bool? requireWifi,
bool? requireCharging,
}) {
return LlmDigestConfigState(
enabled: enabled ?? this.enabled,
cadence: cadence ?? this.cadence,
preferredWeekday: preferredWeekday ?? this.preferredWeekday,
preferredHour: preferredHour ?? this.preferredHour,
requireWifi: requireWifi ?? this.requireWifi,
requireCharging: requireCharging ?? this.requireCharging,
);
}
}
@@ -0,0 +1,50 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/features/ai_digest/application/ai_digest_notifier.dart';
import 'package:relationship_saver/features/ai_digest/presentation/ai_digest_review_view.dart';
class AiDigestNotificationListener extends ConsumerStatefulWidget {
const AiDigestNotificationListener({required this.child, super.key});
final Widget child;
@override
ConsumerState<AiDigestNotificationListener> createState() =>
_AiDigestNotificationListenerState();
}
class _AiDigestNotificationListenerState
extends ConsumerState<AiDigestNotificationListener> {
StreamSubscription<String>? _subscription;
@override
void initState() {
super.initState();
_subscription = ref
.read(aiDigestNotificationIntentBusProvider)
.intents
.listen(_handleIntent);
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
Future<void> _handleIntent(String payload) async {
if (!mounted || payload != aiDigestNotificationPayload) {
return;
}
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (BuildContext context) => const AiDigestReviewView(),
),
);
}
}
@@ -0,0 +1,197 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/app/data/relationship_repository.dart';
import 'package:relationship_saver/app/state/local_data_state.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/ai_digest/domain/ai_digest_models.dart';
import 'package:relationship_saver/features/people/domain/person_models.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart';
class AiDigestReviewView extends ConsumerWidget {
const AiDigestReviewView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final AsyncValue<LocalDataState> localData = ref.watch(
localRepositoryProvider,
);
return Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(title: const Text('AI Review Inbox')),
body: localData.when(
data: (LocalDataState data) => _AiDigestReviewContent(data: data),
loading: () => const Center(child: CircularProgressIndicator()),
error: (Object error, StackTrace stackTrace) =>
Center(child: Text('Unable to load AI suggestions. $error')),
),
);
}
}
class _AiDigestReviewContent extends ConsumerWidget {
const _AiDigestReviewContent({required this.data});
final LocalDataState data;
@override
Widget build(BuildContext context, WidgetRef ref) {
final List<AiSuggestionDraft> pending = data.aiSuggestionDrafts
.where(
(AiSuggestionDraft draft) =>
draft.status == AiSuggestionStatus.pending,
)
.toList(growable: false);
final Map<String, PersonProfile> peopleById = <String, PersonProfile>{
for (final PersonProfile person in data.people) person.id: person,
};
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool compact = constraints.maxWidth < 760;
return SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
compact ? 16 : 28,
compact ? 16 : 24,
compact ? 16 : 28,
28,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Private suggestions',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'Review each suggestion before it changes local data.',
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 16),
if (pending.isEmpty)
const FrostedCard(child: Text('No pending AI suggestions.'))
else
...pending.map(
(AiSuggestionDraft draft) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _AiSuggestionCard(
draft: draft,
person: peopleById[draft.personId],
onAccept: () => ref
.read(localRepositoryProvider.notifier)
.acceptAiSuggestionDraft(draft.id),
onDismiss: () => ref
.read(localRepositoryProvider.notifier)
.dismissAiSuggestionDraft(draft.id),
),
),
),
],
),
);
},
);
}
}
class _AiSuggestionCard extends StatelessWidget {
const _AiSuggestionCard({
required this.draft,
required this.person,
required this.onAccept,
required this.onDismiss,
});
final AiSuggestionDraft draft;
final PersonProfile? person;
final VoidCallback onAccept;
final VoidCallback onDismiss;
@override
Widget build(BuildContext context) {
return FrostedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Icon(_iconForKind(draft.kind), color: AppTheme.primary),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
draft.title,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 4),
Text(
'${_kindLabel(draft.kind)} · ${person?.name ?? 'Unknown person'}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppTheme.textSecondary,
),
),
],
),
),
],
),
if (draft.details.isNotEmpty) ...<Widget>[
const SizedBox(height: 12),
Text(draft.details),
],
if (draft.reason != null &&
draft.reason!.trim().isNotEmpty) ...<Widget>[
const SizedBox(height: 8),
Text(
'Why: ${draft.reason}',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
),
],
const SizedBox(height: 14),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
FilledButton.icon(
onPressed: person == null ? null : onAccept,
icon: const Icon(Icons.check_rounded),
label: const Text('Accept'),
),
OutlinedButton.icon(
onPressed: onDismiss,
icon: const Icon(Icons.close_rounded),
label: const Text('Dismiss'),
),
],
),
],
),
);
}
IconData _iconForKind(AiSuggestionKind kind) {
return switch (kind) {
AiSuggestionKind.giftIdea => Icons.card_giftcard_rounded,
AiSuggestionKind.eventIdea => Icons.event_available_rounded,
AiSuggestionKind.checkIn => Icons.chat_bubble_outline_rounded,
AiSuggestionKind.reminder => Icons.notifications_active_outlined,
};
}
String _kindLabel(AiSuggestionKind kind) {
return switch (kind) {
AiSuggestionKind.giftIdea => 'Gift idea',
AiSuggestionKind.eventIdea => 'Event idea',
AiSuggestionKind.checkIn => 'Check-in',
AiSuggestionKind.reminder => 'Reminder',
};
}
}
+9
View File
@@ -0,0 +1,9 @@
# Auth Slice
This slice owns sign-in UI and session lifecycle.
- `application/session_controller.dart`: async auth session state.
- `presentation/sign_in_view.dart`: sign-in form and environment status UI.
Touch this slice when you change auth UX, local session behavior, or backend
sign-in integration.
+4
View File
@@ -0,0 +1,4 @@
# Auth Application
This layer coordinates auth state and session lifecycle. Start here when you
need to change how the app decides whether a user is signed in.
@@ -0,0 +1,76 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/auth/token_store.dart';
import 'package:relationship_saver/integrations/backend/backend_gateway.dart';
import 'package:relationship_saver/integrations/backend/backend_gateway_provider.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
/// Manages authenticated session lifecycle for app-level navigation.
class SessionController extends AsyncNotifier<AuthSession?> {
@override
Future<AuthSession?> build() async {
return ref.read(tokenStoreProvider).read();
}
Future<void> signIn({
required SignInMethod method,
String? email,
String? password,
String? idToken,
}) async {
state = const AsyncLoading<AuthSession?>();
try {
final BackendGateway gateway = ref.read(backendGatewayProvider);
final AuthSession session = await gateway.signIn(
SignInRequest(
method: method,
email: email,
password: password,
idToken: idToken,
),
);
// Ensure fake gateway sessions are also persisted.
await ref.read(tokenStoreProvider).write(session);
state = AsyncData<AuthSession?>(session);
} catch (error, stackTrace) {
state = AsyncError<AuthSession?>(error, stackTrace);
}
}
Future<void> signOut() async {
final BackendGateway gateway = ref.read(backendGatewayProvider);
final TokenStore tokenStore = ref.read(tokenStoreProvider);
try {
await gateway.signOut();
} catch (_) {
// Offline-first sign-out should always clear local session.
}
await tokenStore.clear();
state = const AsyncData<AuthSession?>(null);
}
Future<void> refreshProfile() async {
final AuthSession? current = state.asData?.value;
if (current == null) {
return;
}
try {
final UserProfile profile = await ref.read(backendGatewayProvider).me();
final AuthSession updated = current.copyWith(user: profile);
await ref.read(tokenStoreProvider).write(updated);
state = AsyncData<AuthSession?>(updated);
} catch (_) {
// Keep current state if profile refresh fails.
}
}
}
final AsyncNotifierProvider<SessionController, AuthSession?>
sessionControllerProvider =
AsyncNotifierProvider<SessionController, AuthSession?>(
SessionController.new,
);
+4
View File
@@ -0,0 +1,4 @@
# Auth Presentation
Auth UI belongs here. Keep sign-in screens, auth-specific widgets, and small
view helpers in this folder rather than mixing them into app bootstrap code.
@@ -0,0 +1,228 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_config.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/auth/session_controller.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
class SignInView extends ConsumerStatefulWidget {
const SignInView({super.key});
@override
ConsumerState<SignInView> createState() => _SignInViewState();
}
class _SignInViewState extends ConsumerState<SignInView> {
SignInMethod _method = SignInMethod.password;
final TextEditingController _emailController = TextEditingController(
text: 'demo@example.com',
);
final TextEditingController _passwordController = TextEditingController(
text: 'password123',
);
final TextEditingController _idTokenController = TextEditingController();
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
_idTokenController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final AsyncValue<AuthSession?> session = ref.watch(
sessionControllerProvider,
);
final bool busy = session.isLoading;
final Object? error = session.asError?.error;
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints viewport) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: viewport.maxHeight - 32),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 620),
child: FrostedCard(
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool compact = constraints.maxWidth < 480;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Sign in to continue',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 8),
Text(
'Your local data remains available offline. Backend unlocks sync and signals.',
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 18),
if (compact)
DropdownButtonFormField<SignInMethod>(
initialValue: _method,
items: SignInMethod.values
.map(
(SignInMethod method) =>
DropdownMenuItem<SignInMethod>(
value: method,
child: Text(_labelForMethod(method)),
),
)
.toList(growable: false),
onChanged: busy
? null
: (SignInMethod? value) {
if (value == null) {
return;
}
setState(() {
_method = value;
});
},
decoration: const InputDecoration(
labelText: 'Method',
),
)
else
SegmentedButton<SignInMethod>(
segments: const <ButtonSegment<SignInMethod>>[
ButtonSegment<SignInMethod>(
value: SignInMethod.password,
label: Text('Password'),
),
ButtonSegment<SignInMethod>(
value: SignInMethod.emailMagicLink,
label: Text('Magic Link'),
),
ButtonSegment<SignInMethod>(
value: SignInMethod.oidc,
label: Text('OIDC'),
),
],
selected: <SignInMethod>{_method},
onSelectionChanged: busy
? null
: (Set<SignInMethod> value) {
setState(() {
_method = value.first;
});
},
),
const SizedBox(height: 14),
if (_method == SignInMethod.password ||
_method == SignInMethod.emailMagicLink)
TextField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
enabled: !busy,
decoration: const InputDecoration(
labelText: 'Email',
),
),
if (_method == SignInMethod.password)
TextField(
controller: _passwordController,
obscureText: true,
enabled: !busy,
decoration: const InputDecoration(
labelText: 'Password',
),
),
if (_method == SignInMethod.oidc)
TextField(
controller: _idTokenController,
enabled: !busy,
decoration: const InputDecoration(
labelText: 'ID Token',
),
),
if (error != null)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(
'Sign-in failed: $error',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: const Color(0xFFC83030)),
),
),
const SizedBox(height: 16),
Wrap(
spacing: 12,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
FilledButton.icon(
onPressed: busy ? null : _submit,
icon: busy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.login_rounded),
label: const Text('Sign In'),
),
Text(
AppConfig.useFakeBackend
? 'Fake mode enabled'
: 'REST mode (${AppConfig.backendBaseUrl})',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: AppTheme.textSecondary),
),
],
),
],
);
},
),
),
),
),
),
);
},
);
}
Future<void> _submit() async {
final String? email = _emailController.text.trim().isEmpty
? null
: _emailController.text.trim();
final String? password = _passwordController.text.trim().isEmpty
? null
: _passwordController.text.trim();
final String? idToken = _idTokenController.text.trim().isEmpty
? null
: _idTokenController.text.trim();
await ref
.read(sessionControllerProvider.notifier)
.signIn(
method: _method,
email: email,
password: password,
idToken: idToken,
);
}
String _labelForMethod(SignInMethod method) {
return switch (method) {
SignInMethod.password => 'Password',
SignInMethod.emailMagicLink => 'Magic Link',
SignInMethod.oidc => 'OIDC',
};
}
}
+2 -76
View File
@@ -1,76 +1,2 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/auth/token_store.dart';
import 'package:relationship_saver/integrations/backend/backend_gateway.dart';
import 'package:relationship_saver/integrations/backend/backend_gateway_provider.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
/// Manages authenticated session lifecycle for app-level navigation.
class SessionController extends AsyncNotifier<AuthSession?> {
@override
Future<AuthSession?> build() async {
return ref.read(tokenStoreProvider).read();
}
Future<void> signIn({
required SignInMethod method,
String? email,
String? password,
String? idToken,
}) async {
state = const AsyncLoading<AuthSession?>();
try {
final BackendGateway gateway = ref.read(backendGatewayProvider);
final AuthSession session = await gateway.signIn(
SignInRequest(
method: method,
email: email,
password: password,
idToken: idToken,
),
);
// Ensure fake gateway sessions are also persisted.
await ref.read(tokenStoreProvider).write(session);
state = AsyncData<AuthSession?>(session);
} catch (error, stackTrace) {
state = AsyncError<AuthSession?>(error, stackTrace);
}
}
Future<void> signOut() async {
final BackendGateway gateway = ref.read(backendGatewayProvider);
final TokenStore tokenStore = ref.read(tokenStoreProvider);
try {
await gateway.signOut();
} catch (_) {
// Offline-first sign-out should always clear local session.
}
await tokenStore.clear();
state = const AsyncData<AuthSession?>(null);
}
Future<void> refreshProfile() async {
final AuthSession? current = state.asData?.value;
if (current == null) {
return;
}
try {
final UserProfile profile = await ref.read(backendGatewayProvider).me();
final AuthSession updated = current.copyWith(user: profile);
await ref.read(tokenStoreProvider).write(updated);
state = AsyncData<AuthSession?>(updated);
} catch (_) {
// Keep current state if profile refresh fails.
}
}
}
final AsyncNotifierProvider<SessionController, AuthSession?>
sessionControllerProvider =
AsyncNotifierProvider<SessionController, AuthSession?>(
SessionController.new,
);
// Legacy compatibility export. Prefer the `application` file in this slice.
export 'package:relationship_saver/features/auth/application/session_controller.dart';
+2 -228
View File
@@ -1,228 +1,2 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_config.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/auth/session_controller.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
class SignInView extends ConsumerStatefulWidget {
const SignInView({super.key});
@override
ConsumerState<SignInView> createState() => _SignInViewState();
}
class _SignInViewState extends ConsumerState<SignInView> {
SignInMethod _method = SignInMethod.password;
final TextEditingController _emailController = TextEditingController(
text: 'demo@example.com',
);
final TextEditingController _passwordController = TextEditingController(
text: 'password123',
);
final TextEditingController _idTokenController = TextEditingController();
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
_idTokenController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final AsyncValue<AuthSession?> session = ref.watch(
sessionControllerProvider,
);
final bool busy = session.isLoading;
final Object? error = session.asError?.error;
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints viewport) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: viewport.maxHeight - 32),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 620),
child: FrostedCard(
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool compact = constraints.maxWidth < 480;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Sign in to continue',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 8),
Text(
'Your local data remains available offline. Backend unlocks sync and signals.',
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 18),
if (compact)
DropdownButtonFormField<SignInMethod>(
initialValue: _method,
items: SignInMethod.values
.map(
(SignInMethod method) =>
DropdownMenuItem<SignInMethod>(
value: method,
child: Text(_labelForMethod(method)),
),
)
.toList(growable: false),
onChanged: busy
? null
: (SignInMethod? value) {
if (value == null) {
return;
}
setState(() {
_method = value;
});
},
decoration: const InputDecoration(
labelText: 'Method',
),
)
else
SegmentedButton<SignInMethod>(
segments: const <ButtonSegment<SignInMethod>>[
ButtonSegment<SignInMethod>(
value: SignInMethod.password,
label: Text('Password'),
),
ButtonSegment<SignInMethod>(
value: SignInMethod.emailMagicLink,
label: Text('Magic Link'),
),
ButtonSegment<SignInMethod>(
value: SignInMethod.oidc,
label: Text('OIDC'),
),
],
selected: <SignInMethod>{_method},
onSelectionChanged: busy
? null
: (Set<SignInMethod> value) {
setState(() {
_method = value.first;
});
},
),
const SizedBox(height: 14),
if (_method == SignInMethod.password ||
_method == SignInMethod.emailMagicLink)
TextField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
enabled: !busy,
decoration: const InputDecoration(
labelText: 'Email',
),
),
if (_method == SignInMethod.password)
TextField(
controller: _passwordController,
obscureText: true,
enabled: !busy,
decoration: const InputDecoration(
labelText: 'Password',
),
),
if (_method == SignInMethod.oidc)
TextField(
controller: _idTokenController,
enabled: !busy,
decoration: const InputDecoration(
labelText: 'ID Token',
),
),
if (error != null)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(
'Sign-in failed: $error',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: const Color(0xFFC83030)),
),
),
const SizedBox(height: 16),
Wrap(
spacing: 12,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
FilledButton.icon(
onPressed: busy ? null : _submit,
icon: busy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.login_rounded),
label: const Text('Sign In'),
),
Text(
AppConfig.useFakeBackend
? 'Fake mode enabled'
: 'REST mode (${AppConfig.backendBaseUrl})',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: AppTheme.textSecondary),
),
],
),
],
);
},
),
),
),
),
),
);
},
);
}
Future<void> _submit() async {
final String? email = _emailController.text.trim().isEmpty
? null
: _emailController.text.trim();
final String? password = _passwordController.text.trim().isEmpty
? null
: _passwordController.text.trim();
final String? idToken = _idTokenController.text.trim().isEmpty
? null
: _idTokenController.text.trim();
await ref
.read(sessionControllerProvider.notifier)
.signIn(
method: _method,
email: email,
password: password,
idToken: idToken,
);
}
String _labelForMethod(SignInMethod method) {
return switch (method) {
SignInMethod.password => 'Password',
SignInMethod.emailMagicLink => 'Magic Link',
SignInMethod.oidc => 'OIDC',
};
}
}
// Legacy compatibility export. Prefer the `presentation` file in this slice.
export 'package:relationship_saver/features/auth/presentation/sign_in_view.dart';
+9
View File
@@ -0,0 +1,9 @@
# Dashboard Slice
This slice owns the home overview experience.
- `domain/dashboard_models.dart`: lightweight dashboard task and summary types.
- `presentation/dashboard_view.dart`: dashboard screen and interactions.
If you want to change the founder daily workflow, this is usually the first
screen to inspect after the app shell.
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
# Dashboard Domain
Dashboard-owned value types live here. These models should stay dumb and stable
so the dashboard UI can evolve without changing persistence contracts everywhere.
@@ -0,0 +1,73 @@
// ignore_for_file: sort_constructors_first
import 'package:flutter/foundation.dart';
/// Lightweight task shown on the dashboard home surface.
@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,
);
}
}
/// Summary numbers consumed by the dashboard UI.
@immutable
class DashboardSummary {
const DashboardSummary({
required this.activePeople,
required this.upcomingPlans,
required this.pendingIdeas,
required this.weeklyConsistency,
});
final int activePeople;
final int upcomingPlans;
final int pendingIdeas;
final int weeklyConsistency;
}
@@ -0,0 +1,4 @@
# Dashboard Presentation
This folder contains the overview/home UI for the relationship workspace.
Use it for founder-facing summary screens, not for deeper people-specific flows.
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
# Home Shell
This folder is a compatibility surface around the app shell. The canonical shell
now lives in `lib/app/presentation/`, so only keep thin exports or temporary glue here.
+2 -391
View File
@@ -1,391 +1,2 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/dashboard/dashboard_view.dart';
import 'package:relationship_saver/features/ideas/ideas_view.dart';
import 'package:relationship_saver/features/moments/moments_view.dart';
import 'package:relationship_saver/features/people/people_view.dart';
import 'package:relationship_saver/features/reminders/reminders_view.dart';
import 'package:relationship_saver/features/settings/settings_view.dart';
import 'package:relationship_saver/features/signals/signals_view.dart';
import 'package:relationship_saver/features/sync/sync_view.dart';
class AppShell extends ConsumerStatefulWidget {
const AppShell({super.key});
@override
ConsumerState<AppShell> createState() => _AppShellState();
}
class _AppShellState extends ConsumerState<AppShell> {
int _index = 0;
static const List<_ShellDestination> _destinations = <_ShellDestination>[
_ShellDestination('Dashboard', Icons.dashboard_rounded, 0),
_ShellDestination('People', Icons.people_alt_rounded, 1),
_ShellDestination('Moments', Icons.auto_awesome_rounded, 2),
_ShellDestination('Signals', Icons.tips_and_updates_rounded, 3),
_ShellDestination('Ideas', Icons.lightbulb_outline_rounded, 4),
_ShellDestination('Reminders', Icons.notifications_active_outlined, 5),
_ShellDestination('Sync', Icons.sync_rounded, 6),
_ShellDestination('Settings', Icons.settings_rounded, 7),
];
static const List<_ShellDestination> _compactPrimaryDestinations =
<_ShellDestination>[
_ShellDestination('Dashboard', Icons.dashboard_rounded, 0),
_ShellDestination('People', Icons.people_alt_rounded, 1),
_ShellDestination('Moments', Icons.auto_awesome_rounded, 2),
_ShellDestination('Signals', Icons.tips_and_updates_rounded, 3),
];
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: <Color>[
Color(0xFFF3F8FB),
Color(0xFFEAF1F8),
Color(0xFFF8FAFC),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
if (constraints.maxWidth < 1000) {
final int compactIndex = _compactSelectedIndex(_index);
final _ShellDestination active =
_compactPrimaryDestinations[compactIndex];
return _CompactShell(
title: active.label,
index: compactIndex,
body: _screenFor(active.screenIndex),
destinations: _compactPrimaryDestinations,
onChange: (int value) {
setState(() {
_index = _compactPrimaryDestinations[value].screenIndex;
});
},
onOpenSecondary: (_SecondaryDestination destination) {
final int screenIndex = switch (destination) {
_SecondaryDestination.ideas => 4,
_SecondaryDestination.reminders => 5,
_SecondaryDestination.sync => 6,
_SecondaryDestination.settings => 7,
};
Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (BuildContext context) {
return _CompactSecondaryScreen(
title: _destinations[screenIndex].label,
child: _screenFor(screenIndex),
);
},
),
);
},
);
}
return Row(
children: <Widget>[
_Sidebar(
index: _index,
destinations: _destinations,
onChange: (int value) {
setState(() {
_index = value;
});
},
),
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
child: KeyedSubtree(
key: ValueKey<int>(_index),
child: _screenFor(_index),
),
),
),
],
);
},
),
),
),
);
}
int _compactSelectedIndex(int fullIndex) {
for (int i = 0; i < _compactPrimaryDestinations.length; i += 1) {
if (_compactPrimaryDestinations[i].screenIndex == fullIndex) {
return i;
}
}
return 0;
}
Widget _screenFor(int index) {
switch (index) {
case 0:
return const DashboardView();
case 1:
return const PeopleView();
case 2:
return const MomentsView();
case 3:
return const SignalsView();
case 4:
return const IdeasView();
case 5:
return const RemindersView();
case 6:
return const SyncView();
case 7:
return const SettingsView();
default:
return const DashboardView();
}
}
}
class _Sidebar extends StatelessWidget {
const _Sidebar({
required this.index,
required this.destinations,
required this.onChange,
});
final int index;
final List<_ShellDestination> destinations;
final ValueChanged<int> onChange;
@override
Widget build(BuildContext context) {
return Container(
width: 290,
margin: const EdgeInsets.fromLTRB(20, 20, 8, 20),
padding: const EdgeInsets.fromLTRB(18, 20, 18, 18),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.78),
borderRadius: BorderRadius.circular(28),
border: Border.all(color: Colors.white.withValues(alpha: 0.7)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
gradient: const LinearGradient(
colors: <Color>[Color(0xFF1AB6C8), Color(0xFF286DE0)],
),
),
child: const Icon(
Icons.favorite_rounded,
color: Colors.white,
size: 20,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
'Relationship Saver',
style: Theme.of(context).textTheme.titleMedium,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 22),
...destinations.indexed.map(((int, _ShellDestination) tuple) {
final int destinationIndex = tuple.$1;
final _ShellDestination destination = tuple.$2;
final bool selected = destinationIndex == index;
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: InkWell(
onTap: () => onChange(destinationIndex),
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
decoration: BoxDecoration(
color: selected
? const Color(0xFFEAF7FB)
: Colors.transparent,
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: <Widget>[
Icon(
destination.icon,
size: 20,
color: selected
? AppTheme.primary
: AppTheme.textSecondary,
),
const SizedBox(width: 10),
Text(
destination.label,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(
color: selected
? AppTheme.primary
: AppTheme.textSecondary,
),
),
],
),
),
),
);
}),
const Spacer(),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFF2F8FB),
borderRadius: BorderRadius.circular(16),
),
child: Text(
'Tip: Use Signals daily and convert at least one into a concrete plan.',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
),
),
],
),
);
}
}
class _CompactShell extends StatelessWidget {
const _CompactShell({
required this.title,
required this.index,
required this.body,
required this.destinations,
required this.onChange,
required this.onOpenSecondary,
});
final String title;
final int index;
final Widget body;
final List<_ShellDestination> destinations;
final ValueChanged<int> onChange;
final ValueChanged<_SecondaryDestination> onOpenSecondary;
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(
children: <Widget>[
Expanded(
child: Text(
title,
style: Theme.of(context).textTheme.titleLarge,
),
),
PopupMenuButton<_SecondaryDestination>(
tooltip: 'More',
icon: const Icon(Icons.more_horiz_rounded),
onSelected: onOpenSecondary,
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<_SecondaryDestination>>[
const PopupMenuItem<_SecondaryDestination>(
value: _SecondaryDestination.ideas,
child: Text('Ideas'),
),
const PopupMenuItem<_SecondaryDestination>(
value: _SecondaryDestination.reminders,
child: Text('Reminders'),
),
const PopupMenuItem<_SecondaryDestination>(
value: _SecondaryDestination.sync,
child: Text('Sync'),
),
const PopupMenuItem<_SecondaryDestination>(
value: _SecondaryDestination.settings,
child: Text('Settings'),
),
],
),
],
),
),
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
child: KeyedSubtree(key: ValueKey<int>(index), child: body),
),
),
NavigationBar(
selectedIndex: index,
onDestinationSelected: onChange,
destinations: destinations
.map(
(_ShellDestination destination) => NavigationDestination(
icon: Icon(destination.icon),
label: destination.label,
),
)
.toList(growable: false),
),
],
);
}
}
class _CompactSecondaryScreen extends StatelessWidget {
const _CompactSecondaryScreen({required this.title, required this.child});
final String title;
final Widget child;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: <Color>[Color(0xFFF3F8FB), Color(0xFFEAF1F8)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(title: Text(title)),
body: child,
),
);
}
}
enum _SecondaryDestination { ideas, reminders, sync, settings }
class _ShellDestination {
const _ShellDestination(this.label, this.icon, this.screenIndex);
final String label;
final IconData icon;
final int screenIndex;
}
// Legacy compatibility export for the app shell.
export 'package:relationship_saver/app/presentation/app_shell.dart';
+9
View File
@@ -0,0 +1,9 @@
# Ideas Slice
This slice owns saved gift and activity ideas.
- `domain/idea_models.dart`: idea types and stored idea records.
- `presentation/ideas_view.dart`: ideas screen and CRUD UI.
Use this slice when you want to improve recommendation surfaces or idea capture
without touching the share pipeline yet.
+4
View File
@@ -0,0 +1,4 @@
# Ideas Domain
Gift ideas, event ideas, and related idea models live here. Keep the models
simple so future recommendation logic can consume them without UI coupling.
@@ -0,0 +1,76 @@
// ignore_for_file: sort_constructors_first
import 'package:flutter/foundation.dart';
/// Higher-level bucket for plan and gift suggestions.
enum IdeaType { gift, event }
/// Saved idea for a person or for the broader relationship backlog.
@immutable
class RelationshipIdea {
const RelationshipIdea({
required this.id,
required this.type,
required this.title,
required this.details,
required this.createdAt,
this.personId,
this.isArchived = false,
});
final String id;
final String? personId;
final IdeaType type;
final String title;
final String details;
final DateTime createdAt;
final bool isArchived;
RelationshipIdea copyWith({
String? id,
String? personId,
IdeaType? type,
String? title,
String? details,
DateTime? createdAt,
bool? isArchived,
}) {
return RelationshipIdea(
id: id ?? this.id,
personId: personId ?? this.personId,
type: type ?? this.type,
title: title ?? this.title,
details: details ?? this.details,
createdAt: createdAt ?? this.createdAt,
isArchived: isArchived ?? this.isArchived,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'personId': personId,
'type': type.name,
'title': title,
'details': details,
'createdAt': createdAt.toUtc().toIso8601String(),
'isArchived': isArchived,
};
}
factory RelationshipIdea.fromJson(Map<String, dynamic> json) {
final String typeName = json['type'] as String? ?? IdeaType.gift.name;
return RelationshipIdea(
id: json['id'] as String,
personId: json['personId'] as String?,
type: IdeaType.values.firstWhere(
(IdeaType type) => type.name == typeName,
orElse: () => IdeaType.gift,
),
title: json['title'] as String,
details: json['details'] as String? ?? '',
createdAt: DateTime.parse(json['createdAt'] as String).toLocal(),
isArchived: json['isArchived'] as bool? ?? false,
);
}
}
+2 -559
View File
@@ -1,559 +1,2 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/local/local_repository.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart';
class IdeasView extends ConsumerWidget {
const IdeasView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final AsyncValue<LocalDataState> localData = ref.watch(
localRepositoryProvider,
);
return localData.when(
data: (LocalDataState data) {
final List<RelationshipIdea> ideas = data.ideas;
final Map<String, PersonProfile> peopleById = <String, PersonProfile>{
for (final PersonProfile person in data.people) person.id: person,
};
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool compact = constraints.maxWidth < 760;
return Padding(
padding: EdgeInsets.fromLTRB(
compact ? 16 : 28,
compact ? 16 : 24,
compact ? 16 : 28,
compact ? 20 : 28,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (compact)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Ideas',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'Capture gift and event ideas before they slip away.',
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: () => _addIdea(context, ref, data.people),
icon: const Icon(Icons.lightbulb_outline_rounded),
label: const Text('Add Idea'),
),
],
)
else
Row(
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Ideas',
style: Theme.of(
context,
).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'Capture gift and event ideas before they slip away.',
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: AppTheme.textSecondary),
),
],
),
),
FilledButton.icon(
onPressed: () => _addIdea(context, ref, data.people),
icon: const Icon(Icons.lightbulb_outline_rounded),
label: const Text('Add Idea'),
),
],
),
const SizedBox(height: 16),
Expanded(
child: ListView.separated(
itemCount: ideas.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (BuildContext context, int index) {
final RelationshipIdea idea = ideas[index];
final PersonProfile? person = idea.personId == null
? null
: peopleById[idea.personId!];
return FrostedCard(
child: compact
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
_IdeaTypeTag(type: idea.type),
const SizedBox(width: 8),
Expanded(
child: Text(
idea.title,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
decoration: idea.isArchived
? TextDecoration
.lineThrough
: null,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
if (idea.details.trim().isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
idea.details,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: AppTheme.textSecondary,
),
),
),
const SizedBox(height: 8),
Text(
person == null
? 'Unassigned'
: person.name,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children: <Widget>[
OutlinedButton.icon(
onPressed: () {
ref
.read(
localRepositoryProvider
.notifier,
)
.toggleIdeaArchived(idea.id);
},
icon: Icon(
idea.isArchived
? Icons.unarchive_outlined
: Icons.archive_outlined,
),
label: Text(
idea.isArchived
? 'Unarchive'
: 'Archive',
),
),
OutlinedButton.icon(
onPressed: () => _editIdea(
context,
ref,
data.people,
idea,
),
icon: const Icon(Icons.edit_rounded),
label: const Text('Edit'),
),
TextButton.icon(
onPressed: () {
ref
.read(
localRepositoryProvider
.notifier,
)
.deleteIdea(idea.id);
},
icon: const Icon(
Icons.delete_outline_rounded,
),
label: const Text('Delete'),
),
],
),
],
)
: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_IdeaTypeTag(type: idea.type),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: <Widget>[
Text(
idea.title,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
decoration: idea.isArchived
? TextDecoration
.lineThrough
: null,
),
),
if (idea.details.trim().isNotEmpty)
Padding(
padding: const EdgeInsets.only(
top: 6,
),
child: Text(
idea.details,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: AppTheme
.textSecondary,
),
),
),
const SizedBox(height: 8),
Text(
person == null
? 'Unassigned'
: person.name,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: AppTheme.textSecondary,
),
),
],
),
),
const SizedBox(width: 8),
Column(
children: <Widget>[
IconButton(
onPressed: () {
ref
.read(
localRepositoryProvider
.notifier,
)
.toggleIdeaArchived(idea.id);
},
icon: Icon(
idea.isArchived
? Icons.unarchive_outlined
: Icons.archive_outlined,
),
tooltip: idea.isArchived
? 'Unarchive'
: 'Archive',
),
IconButton(
onPressed: () => _editIdea(
context,
ref,
data.people,
idea,
),
icon: const Icon(Icons.edit_rounded),
tooltip: 'Edit',
),
IconButton(
onPressed: () {
ref
.read(
localRepositoryProvider
.notifier,
)
.deleteIdea(idea.id);
},
icon: const Icon(
Icons.delete_outline_rounded,
),
tooltip: 'Delete',
),
],
),
],
),
);
},
),
),
],
),
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (Object error, StackTrace stackTrace) {
return const Center(child: Text('Unable to load ideas'));
},
);
}
Future<void> _addIdea(
BuildContext context,
WidgetRef ref,
List<PersonProfile> people,
) async {
final _IdeaDraft? draft = await showDialog<_IdeaDraft>(
context: context,
builder: (BuildContext context) =>
_IdeaEditorDialog(title: 'Add Idea', people: people),
);
if (draft == null) {
return;
}
await ref
.read(localRepositoryProvider.notifier)
.addIdea(
type: draft.type,
title: draft.title,
details: draft.details,
personId: draft.personId,
);
}
Future<void> _editIdea(
BuildContext context,
WidgetRef ref,
List<PersonProfile> people,
RelationshipIdea idea,
) async {
final _IdeaDraft? draft = await showDialog<_IdeaDraft>(
context: context,
builder: (BuildContext context) => _IdeaEditorDialog(
title: 'Edit Idea',
people: people,
initial: _IdeaDraft(
personId: idea.personId,
type: idea.type,
title: idea.title,
details: idea.details,
),
),
);
if (draft == null) {
return;
}
await ref
.read(localRepositoryProvider.notifier)
.updateIdea(
idea.copyWith(
personId: draft.personId,
type: draft.type,
title: draft.title,
details: draft.details,
),
);
}
}
class _IdeaTypeTag extends StatelessWidget {
const _IdeaTypeTag({required this.type});
final IdeaType type;
@override
Widget build(BuildContext context) {
final (Color bg, Color fg, String label) = switch (type) {
IdeaType.gift => (const Color(0xFFEAF7FB), AppTheme.primary, 'GIFT'),
IdeaType.event => (
const Color(0xFFEEF7F3),
const Color(0xFF1D9C66),
'EVENT',
),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(99),
),
child: Text(
label,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: fg,
fontWeight: FontWeight.w700,
),
),
);
}
}
class _IdeaEditorDialog extends StatefulWidget {
const _IdeaEditorDialog({
required this.title,
required this.people,
this.initial,
});
final String title;
final List<PersonProfile> people;
final _IdeaDraft? initial;
@override
State<_IdeaEditorDialog> createState() => _IdeaEditorDialogState();
}
class _IdeaEditorDialogState extends State<_IdeaEditorDialog> {
late final TextEditingController _titleController;
late final TextEditingController _detailsController;
late IdeaType _type;
String? _personId;
@override
void initState() {
super.initState();
_titleController = TextEditingController(text: widget.initial?.title ?? '');
_detailsController = TextEditingController(
text: widget.initial?.details ?? '',
);
_type = widget.initial?.type ?? IdeaType.gift;
_personId = widget.initial?.personId;
}
@override
void dispose() {
_titleController.dispose();
_detailsController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.title),
content: SingleChildScrollView(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 430),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
DropdownButtonFormField<IdeaType>(
initialValue: _type,
items: IdeaType.values
.map(
(IdeaType type) => DropdownMenuItem<IdeaType>(
value: type,
child: Text(type.name.toUpperCase()),
),
)
.toList(growable: false),
onChanged: (IdeaType? value) {
if (value == null) {
return;
}
setState(() {
_type = value;
});
},
decoration: const InputDecoration(labelText: 'Type'),
),
DropdownButtonFormField<String?>(
initialValue: _personId,
items: <DropdownMenuItem<String?>>[
const DropdownMenuItem<String?>(
value: null,
child: Text('Unassigned'),
),
...widget.people.map(
(PersonProfile person) => DropdownMenuItem<String?>(
value: person.id,
child: Text(person.name),
),
),
],
onChanged: (String? value) {
setState(() {
_personId = value;
});
},
decoration: const InputDecoration(labelText: 'Person'),
),
TextField(
controller: _titleController,
decoration: const InputDecoration(labelText: 'Title'),
),
TextField(
controller: _detailsController,
maxLines: 3,
decoration: const InputDecoration(labelText: 'Details'),
),
],
),
),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final String title = _titleController.text.trim();
if (title.isEmpty) {
return;
}
Navigator.of(context).pop(
_IdeaDraft(
personId: _personId,
type: _type,
title: title,
details: _detailsController.text.trim(),
),
);
},
child: const Text('Save'),
),
],
);
}
}
class _IdeaDraft {
const _IdeaDraft({
required this.personId,
required this.type,
required this.title,
required this.details,
});
final String? personId;
final IdeaType type;
final String title;
final String details;
}
// Legacy compatibility export for the ideas presentation entry.
export 'package:relationship_saver/features/ideas/presentation/ideas_view.dart';
@@ -0,0 +1,4 @@
# Ideas Presentation
This folder owns idea-specific screens and widgets. It should answer how ideas
are displayed and edited, not how they are stored or inferred.
@@ -0,0 +1,600 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/local/local_repository.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart';
class IdeasView extends ConsumerStatefulWidget {
const IdeasView({super.key});
@override
ConsumerState<IdeasView> createState() => _IdeasViewState();
}
class _IdeasViewState extends ConsumerState<IdeasView> {
final TextEditingController _searchController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final AsyncValue<LocalDataState> localData = ref.watch(
localRepositoryProvider,
);
return localData.when(
data: (LocalDataState data) {
List<RelationshipIdea> ideas = data.ideas;
final Map<String, PersonProfile> peopleById = <String, PersonProfile>{
for (final PersonProfile person in data.people) person.id: person,
};
if (_searchController.text.isNotEmpty) {
final query = _searchController.text.toLowerCase();
ideas = ideas.where((i) {
final person = i.personId != null ? peopleById[i.personId] : null;
return i.title.toLowerCase().contains(query) ||
i.details.toLowerCase().contains(query) ||
(person?.name.toLowerCase().contains(query) ?? false);
}).toList();
}
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool compact = constraints.maxWidth < 760;
return Padding(
padding: EdgeInsets.fromLTRB(
compact ? 16 : 28,
compact ? 16 : 24,
compact ? 16 : 28,
compact ? 20 : 28,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (compact)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Ideas',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'Capture gift and event ideas before they slip away.',
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: () => _addIdea(context, ref, data.people),
icon: const Icon(Icons.lightbulb_outline_rounded),
label: const Text('Add Idea'),
),
],
)
else
Row(
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Ideas',
style: Theme.of(
context,
).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'Capture gift and event ideas before they slip away.',
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: AppTheme.textSecondary),
),
],
),
),
FilledButton.icon(
onPressed: () => _addIdea(context, ref, data.people),
icon: const Icon(Icons.lightbulb_outline_rounded),
label: const Text('Add Idea'),
),
],
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search ideas...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
onChanged: (_) => setState(() {}),
),
),
Expanded(
child: ListView.separated(
itemCount: ideas.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (BuildContext context, int index) {
final RelationshipIdea idea = ideas[index];
final PersonProfile? person = idea.personId == null
? null
: peopleById[idea.personId!];
return FrostedCard(
child: compact
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
_IdeaTypeTag(type: idea.type),
const SizedBox(width: 8),
Expanded(
child: Text(
idea.title,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
decoration: idea.isArchived
? TextDecoration
.lineThrough
: null,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
if (idea.details.trim().isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
idea.details,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: AppTheme.textSecondary,
),
),
),
const SizedBox(height: 8),
Text(
person == null
? 'Unassigned'
: person.name,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children: <Widget>[
OutlinedButton.icon(
onPressed: () {
ref
.read(
localRepositoryProvider
.notifier,
)
.toggleIdeaArchived(idea.id);
},
icon: Icon(
idea.isArchived
? Icons.unarchive_outlined
: Icons.archive_outlined,
),
label: Text(
idea.isArchived
? 'Unarchive'
: 'Archive',
),
),
OutlinedButton.icon(
onPressed: () => _editIdea(
context,
ref,
data.people,
idea,
),
icon: const Icon(Icons.edit_rounded),
label: const Text('Edit'),
),
TextButton.icon(
onPressed: () {
ref
.read(
localRepositoryProvider
.notifier,
)
.deleteIdea(idea.id);
},
icon: const Icon(
Icons.delete_outline_rounded,
),
label: const Text('Delete'),
),
],
),
],
)
: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_IdeaTypeTag(type: idea.type),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: <Widget>[
Text(
idea.title,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
decoration: idea.isArchived
? TextDecoration
.lineThrough
: null,
),
),
if (idea.details.trim().isNotEmpty)
Padding(
padding: const EdgeInsets.only(
top: 6,
),
child: Text(
idea.details,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: AppTheme
.textSecondary,
),
),
),
const SizedBox(height: 8),
Text(
person == null
? 'Unassigned'
: person.name,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: AppTheme.textSecondary,
),
),
],
),
),
const SizedBox(width: 8),
Column(
children: <Widget>[
IconButton(
onPressed: () {
ref
.read(
localRepositoryProvider
.notifier,
)
.toggleIdeaArchived(idea.id);
},
icon: Icon(
idea.isArchived
? Icons.unarchive_outlined
: Icons.archive_outlined,
),
tooltip: idea.isArchived
? 'Unarchive'
: 'Archive',
),
IconButton(
onPressed: () => _editIdea(
context,
ref,
data.people,
idea,
),
icon: const Icon(Icons.edit_rounded),
tooltip: 'Edit',
),
IconButton(
onPressed: () {
ref
.read(
localRepositoryProvider
.notifier,
)
.deleteIdea(idea.id);
},
icon: const Icon(
Icons.delete_outline_rounded,
),
tooltip: 'Delete',
),
],
),
],
),
);
},
),
),
],
),
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (Object error, StackTrace stackTrace) {
return const Center(child: Text('Unable to load ideas'));
},
);
}
Future<void> _addIdea(
BuildContext context,
WidgetRef ref,
List<PersonProfile> people,
) async {
final _IdeaDraft? draft = await showDialog<_IdeaDraft>(
context: context,
builder: (BuildContext context) =>
_IdeaEditorDialog(title: 'Add Idea', people: people),
);
if (draft == null) {
return;
}
await ref
.read(localRepositoryProvider.notifier)
.addIdea(
type: draft.type,
title: draft.title,
details: draft.details,
personId: draft.personId,
);
}
Future<void> _editIdea(
BuildContext context,
WidgetRef ref,
List<PersonProfile> people,
RelationshipIdea idea,
) async {
final _IdeaDraft? draft = await showDialog<_IdeaDraft>(
context: context,
builder: (BuildContext context) => _IdeaEditorDialog(
title: 'Edit Idea',
people: people,
initial: _IdeaDraft(
personId: idea.personId,
type: idea.type,
title: idea.title,
details: idea.details,
),
),
);
if (draft == null) {
return;
}
await ref
.read(localRepositoryProvider.notifier)
.updateIdea(
idea.copyWith(
personId: draft.personId,
type: draft.type,
title: draft.title,
details: draft.details,
),
);
}
}
class _IdeaTypeTag extends StatelessWidget {
const _IdeaTypeTag({required this.type});
final IdeaType type;
@override
Widget build(BuildContext context) {
final (Color bg, Color fg, String label) = switch (type) {
IdeaType.gift => (const Color(0xFFEAF7FB), AppTheme.primary, 'GIFT'),
IdeaType.event => (
const Color(0xFFEEF7F3),
const Color(0xFF1D9C66),
'EVENT',
),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(99),
),
child: Text(
label,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: fg,
fontWeight: FontWeight.w700,
),
),
);
}
}
class _IdeaEditorDialog extends StatefulWidget {
const _IdeaEditorDialog({
required this.title,
required this.people,
this.initial,
});
final String title;
final List<PersonProfile> people;
final _IdeaDraft? initial;
@override
State<_IdeaEditorDialog> createState() => _IdeaEditorDialogState();
}
class _IdeaEditorDialogState extends State<_IdeaEditorDialog> {
late final TextEditingController _titleController;
late final TextEditingController _detailsController;
late IdeaType _type;
String? _personId;
@override
void initState() {
super.initState();
_titleController = TextEditingController(text: widget.initial?.title ?? '');
_detailsController = TextEditingController(
text: widget.initial?.details ?? '',
);
_type = widget.initial?.type ?? IdeaType.gift;
_personId = widget.initial?.personId;
}
@override
void dispose() {
_titleController.dispose();
_detailsController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.title),
content: SingleChildScrollView(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 430),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
DropdownButtonFormField<IdeaType>(
initialValue: _type,
items: IdeaType.values
.map(
(IdeaType type) => DropdownMenuItem<IdeaType>(
value: type,
child: Text(type.name.toUpperCase()),
),
)
.toList(growable: false),
onChanged: (IdeaType? value) {
if (value == null) {
return;
}
setState(() {
_type = value;
});
},
decoration: const InputDecoration(labelText: 'Type'),
),
DropdownButtonFormField<String?>(
initialValue: _personId,
items: <DropdownMenuItem<String?>>[
const DropdownMenuItem<String?>(
value: null,
child: Text('Unassigned'),
),
...widget.people.map(
(PersonProfile person) => DropdownMenuItem<String?>(
value: person.id,
child: Text(person.name),
),
),
],
onChanged: (String? value) {
setState(() {
_personId = value;
});
},
decoration: const InputDecoration(labelText: 'Person'),
),
TextField(
controller: _titleController,
decoration: const InputDecoration(labelText: 'Title'),
),
TextField(
controller: _detailsController,
maxLines: 3,
decoration: const InputDecoration(labelText: 'Details'),
),
],
),
),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final String title = _titleController.text.trim();
if (title.isEmpty) {
return;
}
Navigator.of(context).pop(
_IdeaDraft(
personId: _personId,
type: _type,
title: title,
details: _detailsController.text.trim(),
),
);
},
child: const Text('Save'),
),
],
);
}
}
class _IdeaDraft {
const _IdeaDraft({
required this.personId,
required this.type,
required this.title,
required this.details,
});
final String? personId;
final IdeaType type;
final String title;
final String details;
}
+8
View File
@@ -0,0 +1,8 @@
# Legacy Local Layer
This folder is now a compatibility layer.
The old horizontal `local` module was the main architectural weak spot. Its
root files now re-export canonical code from `lib/app/` and feature-owned
`domain/` files so older imports keep working while the real implementation
lives in clearer locations.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
# Legacy Local Storage Exports
These files exist as compatibility exports while the canonical storage layer has
moved to `lib/app/data/storage/`. Prefer editing the app-layer storage files.
@@ -1,19 +1,2 @@
/// Raw local state payload and associated schema version.
class LocalDataRecord {
const LocalDataRecord({required this.rawState, required this.schemaVersion});
final String rawState;
final int schemaVersion;
}
/// Persistence boundary for local app state.
abstract interface class LocalDataStore {
/// Reads latest persisted state payload.
Future<LocalDataRecord?> read();
/// Writes state payload and schema version.
Future<void> write({required String rawState, required int schemaVersion});
/// Clears persisted state payload.
Future<void> clear();
}
// Legacy compatibility export for the app storage contract.
export 'package:relationship_saver/app/data/storage/local_data_store.dart';
@@ -1,54 +1,2 @@
import 'package:hive_flutter/hive_flutter.dart';
import 'package:relationship_saver/features/local/storage/local_data_store.dart';
/// Hive-backed local store for future DB migration path.
class HiveLocalDataStore implements LocalDataStore {
static const String _boxName = 'relationship_saver_local';
static const String _stateKey = 'state_json';
static const String _schemaVersionKey = 'schema_version';
static bool _initialized = false;
@override
Future<void> clear() async {
final Box<dynamic> box = await _openBox();
await box.delete(_stateKey);
await box.delete(_schemaVersionKey);
}
@override
Future<LocalDataRecord?> read() async {
final Box<dynamic> box = await _openBox();
final String? rawState = box.get(_stateKey) as String?;
if (rawState == null || rawState.isEmpty) {
return null;
}
final int schemaVersion =
(box.get(_schemaVersionKey) as num?)?.toInt() ?? 0;
return LocalDataRecord(rawState: rawState, schemaVersion: schemaVersion);
}
@override
Future<void> write({
required String rawState,
required int schemaVersion,
}) async {
final Box<dynamic> box = await _openBox();
await box.put(_stateKey, rawState);
await box.put(_schemaVersionKey, schemaVersion);
}
Future<Box<dynamic>> _openBox() async {
if (!_initialized) {
await Hive.initFlutter();
_initialized = true;
}
if (!Hive.isBoxOpen(_boxName)) {
return Hive.openBox<dynamic>(_boxName);
}
return Hive.box<dynamic>(_boxName);
}
}
// Legacy compatibility export for the Hive local data store.
export 'package:relationship_saver/app/data/storage/local_data_store_hive.dart';
@@ -1,22 +1,2 @@
import 'package:relationship_saver/features/local/storage/local_data_store.dart';
/// In-memory local store for deterministic tests.
class InMemoryLocalDataStore implements LocalDataStore {
LocalDataRecord? _record;
@override
Future<void> clear() async {
_record = null;
}
@override
Future<LocalDataRecord?> read() async => _record;
@override
Future<void> write({
required String rawState,
required int schemaVersion,
}) async {
_record = LocalDataRecord(rawState: rawState, schemaVersion: schemaVersion);
}
}
// Legacy compatibility export for the in-memory local data store.
export 'package:relationship_saver/app/data/storage/local_data_store_in_memory.dart';
@@ -1,14 +1,2 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_config.dart';
import 'package:relationship_saver/features/local/storage/local_data_store.dart';
import 'package:relationship_saver/features/local/storage/local_data_store_hive.dart';
import 'package:relationship_saver/features/local/storage/local_data_store_shared_prefs.dart';
/// Chooses local persistence backend for app state.
final Provider<LocalDataStore> localDataStoreProvider =
Provider<LocalDataStore>((Ref ref) {
if (AppConfig.useHiveLocalDb) {
return HiveLocalDataStore();
}
return SharedPrefsLocalDataStore();
});
// Legacy compatibility export for the local data store provider.
export 'package:relationship_saver/app/data/storage/local_data_store_provider.dart';
@@ -1,44 +1,2 @@
import 'package:relationship_saver/features/local/storage/local_data_store.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// shared_preferences-backed local store used as stable default.
class SharedPrefsLocalDataStore implements LocalDataStore {
SharedPrefsLocalDataStore({
this.stateKey = 'local_data_state_v1',
this.schemaVersionKey = 'local_data_schema_version',
});
final String stateKey;
final String schemaVersionKey;
@override
Future<void> clear() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.remove(stateKey);
await prefs.remove(schemaVersionKey);
}
@override
Future<LocalDataRecord?> read() async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
final String? rawState = prefs.getString(stateKey);
if (rawState == null || rawState.isEmpty) {
return null;
}
return LocalDataRecord(
rawState: rawState,
schemaVersion: prefs.getInt(schemaVersionKey) ?? 0,
);
}
@override
Future<void> write({
required String rawState,
required int schemaVersion,
}) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
await prefs.setString(stateKey, rawState);
await prefs.setInt(schemaVersionKey, schemaVersion);
}
}
// Legacy compatibility export for the shared preferences local store.
export 'package:relationship_saver/app/data/storage/local_data_store_shared_prefs.dart';
+9
View File
@@ -0,0 +1,9 @@
# Moments Slice
This slice owns the lightweight relationship timeline.
- `domain/moment_models.dart`: stored moment model.
- `presentation/moments_view.dart`: moments UI and quick capture paths.
Moments are still simpler than structured facts. They are good for fast manual
logging and timeline history.
+4
View File
@@ -0,0 +1,4 @@
# Moments Domain
Relationship capture and timeline models live here. Keep this folder focused on
recorded events and notes about interactions.
@@ -0,0 +1,63 @@
// ignore_for_file: sort_constructors_first
import 'package:flutter/foundation.dart';
/// Timeline event stored for a specific relationship.
@immutable
class RelationshipMoment {
const RelationshipMoment({
required this.id,
required this.personId,
required this.title,
required this.summary,
required this.at,
required this.type,
});
final String id;
final String personId;
final String title;
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,
);
}
}
+2 -344
View File
@@ -1,344 +1,2 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/local/local_repository.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart';
class MomentsView extends ConsumerStatefulWidget {
const MomentsView({super.key});
@override
ConsumerState<MomentsView> createState() => _MomentsViewState();
}
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 LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool compact = constraints.maxWidth < 760;
return Padding(
padding: EdgeInsets.fromLTRB(
compact ? 16 : 28,
compact ? 16 : 24,
compact ? 16 : 28,
compact ? 20 : 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(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
TextField(
controller: _captureController,
minLines: 1,
maxLines: compact ? 4 : 2,
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),
if (compact)
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
if (people.isNotEmpty)
DropdownButtonFormField<String>(
initialValue: activePerson,
items: people
.map(
(PersonProfile person) =>
DropdownMenuItem<String>(
value: person.id,
child: Text(person.name),
),
)
.toList(growable: false),
onChanged: (String? value) {
setState(() {
_selectedPersonId = value;
});
},
decoration: const InputDecoration(
labelText: 'Person',
),
),
const SizedBox(height: 10),
FilledButton.icon(
onPressed:
people.isEmpty || activePerson == null
? null
: () => _addCapture(activePerson),
icon: const Icon(Icons.add_rounded),
label: const Text('Add Capture'),
),
],
)
else
Row(
children: <Widget>[
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(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: compact
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Container(
width: 10,
height: 10,
decoration: const BoxDecoration(
color: Color(0xFF1AB6C8),
shape: BoxShape.circle,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
moment.title,
style: Theme.of(
context,
).textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
onPressed: () {
ref
.read(
localRepositoryProvider
.notifier,
)
.deleteMoment(moment.id);
},
icon: const Icon(
Icons.delete_outline_rounded,
),
tooltip: 'Delete capture',
),
],
),
Text(
'${person?.name ?? 'Unknown'}${moment.type.toUpperCase()}${moment.at.month}/${moment.at.day}',
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
Text(
moment.summary,
style: Theme.of(
context,
).textTheme.bodyLarge,
),
],
)
: 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();
}
}
// Legacy compatibility export for the moments presentation entry.
export 'package:relationship_saver/features/moments/presentation/moments_view.dart';
@@ -0,0 +1,4 @@
# Moments Presentation
Moment timeline UI belongs here. If you are improving how captures are browsed
or edited, this is the slice entry point.
@@ -0,0 +1,372 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/local/local_repository.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart';
class MomentsView extends ConsumerStatefulWidget {
const MomentsView({super.key});
@override
ConsumerState<MomentsView> createState() => _MomentsViewState();
}
class _MomentsViewState extends ConsumerState<MomentsView> {
final TextEditingController _captureController = TextEditingController();
final TextEditingController _searchController = TextEditingController();
String? _selectedPersonId;
@override
void dispose() {
_captureController.dispose();
_searchController.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;
List<RelationshipMoment> moments = data.moments;
final Map<String, PersonProfile> peopleById = <String, PersonProfile>{
for (final PersonProfile person in people) person.id: person,
};
if (_searchController.text.isNotEmpty) {
final query = _searchController.text.toLowerCase();
moments = moments.where((m) {
final person = peopleById[m.personId];
return m.title.toLowerCase().contains(query) ||
m.summary.toLowerCase().contains(query) ||
(person?.name.toLowerCase().contains(query) ?? false);
}).toList();
}
final String? fallbackPersonId = people.isEmpty
? null
: people.first.id;
final String? activePerson =
people.any((PersonProfile p) => p.id == _selectedPersonId)
? _selectedPersonId
: fallbackPersonId;
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool compact = constraints.maxWidth < 760;
return Padding(
padding: EdgeInsets.fromLTRB(
compact ? 16 : 28,
compact ? 16 : 24,
compact ? 16 : 28,
compact ? 20 : 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),
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search moments...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 16),
FrostedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
TextField(
controller: _captureController,
minLines: 1,
maxLines: compact ? 4 : 2,
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),
if (compact)
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
if (people.isNotEmpty)
DropdownButtonFormField<String>(
initialValue: activePerson,
items: people
.map(
(PersonProfile person) =>
DropdownMenuItem<String>(
value: person.id,
child: Text(person.name),
),
)
.toList(growable: false),
onChanged: (String? value) {
setState(() {
_selectedPersonId = value;
});
},
decoration: const InputDecoration(
labelText: 'Person',
),
),
const SizedBox(height: 10),
FilledButton.icon(
onPressed:
people.isEmpty || activePerson == null
? null
: () => _addCapture(activePerson),
icon: const Icon(Icons.add_rounded),
label: const Text('Add Capture'),
),
],
)
else
Row(
children: <Widget>[
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(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: compact
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Container(
width: 10,
height: 10,
decoration: const BoxDecoration(
color: Color(0xFF1AB6C8),
shape: BoxShape.circle,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
moment.title,
style: Theme.of(
context,
).textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
onPressed: () {
ref
.read(
localRepositoryProvider
.notifier,
)
.deleteMoment(moment.id);
},
icon: const Icon(
Icons.delete_outline_rounded,
),
tooltip: 'Delete capture',
),
],
),
Text(
'${person?.name ?? 'Unknown'}${moment.type.toUpperCase()}${moment.at.month}/${moment.at.day}',
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
Text(
moment.summary,
style: Theme.of(
context,
).textTheme.bodyLarge,
),
],
)
: 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();
}
}
+12
View File
@@ -0,0 +1,12 @@
# People Slice
This is the richest slice in the app and the main relationship workspace.
- `domain/person_models.dart`: person profile, facts, dates, preference signals,
and source-link models.
- `presentation/people_view.dart`: main people workspace screen.
- `presentation/providers/people_ui_state.dart`: list/search/filter selection
state for the people workspace.
If you want to extend person intelligence, start in `domain/` for data shape
and then wire the UI through `presentation/`.
+4
View File
@@ -0,0 +1,4 @@
# People Domain
Person profiles, structured facts, dates, source links, and preference signals
live here. These are core app entities, so keep them framework-free and explicit.
@@ -0,0 +1,538 @@
// ignore_for_file: sort_constructors_first
import 'package:flutter/foundation.dart';
import 'package:relationship_saver/features/share_intake/domain/share_models.dart';
/// Polarity for inferred or confirmed preference signals.
enum PreferenceSignalPolarity { like, dislike, neutral }
/// Lifecycle state for machine-assisted preference signals.
enum PreferenceSignalStatus { inferred, confirmed, dismissed }
/// Primary profile object used across the relationship app.
@immutable
class PersonProfile {
const PersonProfile({
required this.id,
required this.name,
required this.relationship,
required this.affinityScore,
required this.nextMoment,
required this.tags,
required this.notes,
this.aliases = const <String>[],
this.location,
this.lastUpdatedAt,
this.lastInteractedAt,
});
final String id;
final String name;
final String relationship;
final int affinityScore;
final DateTime nextMoment;
final List<String> tags;
final String notes;
final List<String> aliases;
final String? location;
final DateTime? lastUpdatedAt;
final DateTime? lastInteractedAt;
PersonProfile copyWith({
String? id,
String? name,
String? relationship,
int? affinityScore,
DateTime? nextMoment,
List<String>? tags,
String? notes,
List<String>? aliases,
String? location,
DateTime? lastUpdatedAt,
DateTime? lastInteractedAt,
}) {
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,
aliases: aliases ?? this.aliases,
location: location ?? this.location,
lastUpdatedAt: lastUpdatedAt ?? this.lastUpdatedAt,
lastInteractedAt: lastInteractedAt ?? this.lastInteractedAt,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'name': name,
'relationship': relationship,
'affinityScore': affinityScore,
'nextMoment': nextMoment.toUtc().toIso8601String(),
'tags': tags,
'notes': notes,
'aliases': aliases,
'location': location,
'lastUpdatedAt': lastUpdatedAt?.toUtc().toIso8601String(),
'lastInteractedAt': lastInteractedAt?.toUtc().toIso8601String(),
};
}
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>? ?? <dynamic>[])
.map((dynamic tag) => '$tag')
.toList(growable: false),
notes: json['notes'] as String? ?? '',
aliases: (json['aliases'] as List<dynamic>? ?? <dynamic>[])
.map((dynamic value) => '$value')
.toList(growable: false),
location: json['location'] as String?,
lastUpdatedAt: json['lastUpdatedAt'] == null
? null
: DateTime.parse(json['lastUpdatedAt'] as String).toLocal(),
lastInteractedAt: json['lastInteractedAt'] == null
? null
: DateTime.parse(json['lastInteractedAt'] as String).toLocal(),
);
}
}
/// Stable mapping between an external sender/thread identity and a person.
@immutable
class SourceProfileLink {
const SourceProfileLink({
required this.id,
required this.sourceApp,
required this.normalizedDisplayName,
required this.profileId,
required this.firstSeenAt,
required this.lastSeenAt,
this.sourceUserId,
this.sourceThreadId,
this.sourceFingerprint,
});
final String id;
final String sourceApp;
final String? sourceUserId;
final String? sourceThreadId;
final String? sourceFingerprint;
final String normalizedDisplayName;
final String profileId;
final DateTime firstSeenAt;
final DateTime lastSeenAt;
SourceProfileLink copyWith({
String? id,
String? sourceApp,
String? sourceUserId,
String? sourceThreadId,
String? sourceFingerprint,
String? normalizedDisplayName,
String? profileId,
DateTime? firstSeenAt,
DateTime? lastSeenAt,
}) {
return SourceProfileLink(
id: id ?? this.id,
sourceApp: sourceApp ?? this.sourceApp,
sourceUserId: sourceUserId ?? this.sourceUserId,
sourceThreadId: sourceThreadId ?? this.sourceThreadId,
sourceFingerprint: sourceFingerprint ?? this.sourceFingerprint,
normalizedDisplayName:
normalizedDisplayName ?? this.normalizedDisplayName,
profileId: profileId ?? this.profileId,
firstSeenAt: firstSeenAt ?? this.firstSeenAt,
lastSeenAt: lastSeenAt ?? this.lastSeenAt,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'sourceApp': sourceApp,
'sourceUserId': sourceUserId,
'sourceThreadId': sourceThreadId,
'sourceFingerprint': sourceFingerprint,
'normalizedDisplayName': normalizedDisplayName,
'profileId': profileId,
'firstSeenAt': firstSeenAt.toUtc().toIso8601String(),
'lastSeenAt': lastSeenAt.toUtc().toIso8601String(),
};
}
factory SourceProfileLink.fromJson(Map<String, dynamic> json) {
return SourceProfileLink(
id: json['id'] as String,
sourceApp: json['sourceApp'] as String,
sourceUserId: json['sourceUserId'] as String?,
sourceThreadId: json['sourceThreadId'] as String?,
sourceFingerprint: json['sourceFingerprint'] as String?,
normalizedDisplayName: json['normalizedDisplayName'] as String? ?? '',
profileId: json['profileId'] as String,
firstSeenAt: DateTime.parse(json['firstSeenAt'] as String).toLocal(),
lastSeenAt: DateTime.parse(json['lastSeenAt'] as String).toLocal(),
);
}
}
/// Structured fact captured about a person.
@immutable
class PersonFact {
const PersonFact({
required this.id,
required this.personId,
required this.type,
required this.text,
required this.sourceKind,
required this.createdAt,
required this.updatedAt,
this.label,
this.sourceApp,
this.sourceUrl,
this.sharedMessageId,
this.inboxEntryId,
this.confidence,
this.needsReview = false,
this.isSensitive = false,
});
final String id;
final String personId;
final CapturedFactType type;
final String text;
final String? label;
final CaptureSourceKind sourceKind;
final String? sourceApp;
final String? sourceUrl;
final String? sharedMessageId;
final String? inboxEntryId;
final double? confidence;
final bool needsReview;
final bool isSensitive;
final DateTime createdAt;
final DateTime updatedAt;
PersonFact copyWith({
String? id,
String? personId,
CapturedFactType? type,
String? text,
String? label,
CaptureSourceKind? sourceKind,
String? sourceApp,
String? sourceUrl,
String? sharedMessageId,
String? inboxEntryId,
double? confidence,
bool? needsReview,
bool? isSensitive,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return PersonFact(
id: id ?? this.id,
personId: personId ?? this.personId,
type: type ?? this.type,
text: text ?? this.text,
label: label ?? this.label,
sourceKind: sourceKind ?? this.sourceKind,
sourceApp: sourceApp ?? this.sourceApp,
sourceUrl: sourceUrl ?? this.sourceUrl,
sharedMessageId: sharedMessageId ?? this.sharedMessageId,
inboxEntryId: inboxEntryId ?? this.inboxEntryId,
confidence: confidence ?? this.confidence,
needsReview: needsReview ?? this.needsReview,
isSensitive: isSensitive ?? this.isSensitive,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'personId': personId,
'type': type.name,
'text': text,
'label': label,
'sourceKind': sourceKind.name,
'sourceApp': sourceApp,
'sourceUrl': sourceUrl,
'sharedMessageId': sharedMessageId,
'inboxEntryId': inboxEntryId,
'confidence': confidence,
'needsReview': needsReview,
'isSensitive': isSensitive,
'createdAt': createdAt.toUtc().toIso8601String(),
'updatedAt': updatedAt.toUtc().toIso8601String(),
};
}
factory PersonFact.fromJson(Map<String, dynamic> json) {
final String typeName =
json['type'] as String? ?? CapturedFactType.note.name;
final String sourceKindName =
json['sourceKind'] as String? ?? CaptureSourceKind.manual.name;
return PersonFact(
id: json['id'] as String,
personId: json['personId'] as String,
type: CapturedFactType.values.firstWhere(
(CapturedFactType value) => value.name == typeName,
orElse: () => CapturedFactType.note,
),
text: json['text'] as String? ?? '',
label: json['label'] as String?,
sourceKind: CaptureSourceKind.values.firstWhere(
(CaptureSourceKind value) => value.name == sourceKindName,
orElse: () => CaptureSourceKind.manual,
),
sourceApp: json['sourceApp'] as String?,
sourceUrl: json['sourceUrl'] as String?,
sharedMessageId: json['sharedMessageId'] as String?,
inboxEntryId: json['inboxEntryId'] as String?,
confidence: (json['confidence'] as num?)?.toDouble(),
needsReview: json['needsReview'] as bool? ?? false,
isSensitive: json['isSensitive'] as bool? ?? false,
createdAt: DateTime.parse(json['createdAt'] as String).toLocal(),
updatedAt: DateTime.parse(json['updatedAt'] as String).toLocal(),
);
}
}
/// Important date owned by a person profile.
@immutable
class PersonImportantDate {
const PersonImportantDate({
required this.id,
required this.personId,
required this.label,
required this.date,
required this.classification,
required this.sourceKind,
required this.createdAt,
required this.updatedAt,
this.sourceApp,
this.inboxEntryId,
this.isSensitive = false,
});
final String id;
final String personId;
final String label;
final DateTime date;
final String classification;
final CaptureSourceKind sourceKind;
final String? sourceApp;
final String? inboxEntryId;
final bool isSensitive;
final DateTime createdAt;
final DateTime updatedAt;
PersonImportantDate copyWith({
String? id,
String? personId,
String? label,
DateTime? date,
String? classification,
CaptureSourceKind? sourceKind,
String? sourceApp,
String? inboxEntryId,
bool? isSensitive,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return PersonImportantDate(
id: id ?? this.id,
personId: personId ?? this.personId,
label: label ?? this.label,
date: date ?? this.date,
classification: classification ?? this.classification,
sourceKind: sourceKind ?? this.sourceKind,
sourceApp: sourceApp ?? this.sourceApp,
inboxEntryId: inboxEntryId ?? this.inboxEntryId,
isSensitive: isSensitive ?? this.isSensitive,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'personId': personId,
'label': label,
'date': date.toUtc().toIso8601String(),
'classification': classification,
'sourceKind': sourceKind.name,
'sourceApp': sourceApp,
'inboxEntryId': inboxEntryId,
'isSensitive': isSensitive,
'createdAt': createdAt.toUtc().toIso8601String(),
'updatedAt': updatedAt.toUtc().toIso8601String(),
};
}
factory PersonImportantDate.fromJson(Map<String, dynamic> json) {
final String sourceKindName =
json['sourceKind'] as String? ?? CaptureSourceKind.manual.name;
return PersonImportantDate(
id: json['id'] as String,
personId: json['personId'] as String,
label: json['label'] as String? ?? '',
date: DateTime.parse(json['date'] as String).toLocal(),
classification: json['classification'] as String? ?? 'important',
sourceKind: CaptureSourceKind.values.firstWhere(
(CaptureSourceKind value) => value.name == sourceKindName,
orElse: () => CaptureSourceKind.manual,
),
sourceApp: json['sourceApp'] as String?,
inboxEntryId: json['inboxEntryId'] as String?,
isSensitive: json['isSensitive'] as bool? ?? false,
createdAt: DateTime.parse(json['createdAt'] as String).toLocal(),
updatedAt: DateTime.parse(json['updatedAt'] as String).toLocal(),
);
}
}
/// Inferred preference signal that can later drive recommendations or prompts.
@immutable
class PersonPreferenceSignal {
const PersonPreferenceSignal({
required this.id,
required this.personId,
required this.key,
required this.label,
required this.category,
required this.polarity,
required this.confidence,
required this.status,
required this.firstSeenAt,
required this.lastSeenAt,
required this.occurrenceCount,
this.sourceApps = const <String>[],
this.evidenceMessageIds = const <String>[],
this.evidenceSnippets = const <String>[],
});
final String id;
final String personId;
final String key;
final String label;
final String category;
final PreferenceSignalPolarity polarity;
final double confidence;
final PreferenceSignalStatus status;
final DateTime firstSeenAt;
final DateTime lastSeenAt;
final int occurrenceCount;
final List<String> sourceApps;
final List<String> evidenceMessageIds;
final List<String> evidenceSnippets;
PersonPreferenceSignal copyWith({
String? id,
String? personId,
String? key,
String? label,
String? category,
PreferenceSignalPolarity? polarity,
double? confidence,
PreferenceSignalStatus? status,
DateTime? firstSeenAt,
DateTime? lastSeenAt,
int? occurrenceCount,
List<String>? sourceApps,
List<String>? evidenceMessageIds,
List<String>? evidenceSnippets,
}) {
return PersonPreferenceSignal(
id: id ?? this.id,
personId: personId ?? this.personId,
key: key ?? this.key,
label: label ?? this.label,
category: category ?? this.category,
polarity: polarity ?? this.polarity,
confidence: confidence ?? this.confidence,
status: status ?? this.status,
firstSeenAt: firstSeenAt ?? this.firstSeenAt,
lastSeenAt: lastSeenAt ?? this.lastSeenAt,
occurrenceCount: occurrenceCount ?? this.occurrenceCount,
sourceApps: sourceApps ?? this.sourceApps,
evidenceMessageIds: evidenceMessageIds ?? this.evidenceMessageIds,
evidenceSnippets: evidenceSnippets ?? this.evidenceSnippets,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'personId': personId,
'key': key,
'label': label,
'category': category,
'polarity': polarity.name,
'confidence': confidence,
'status': status.name,
'firstSeenAt': firstSeenAt.toUtc().toIso8601String(),
'lastSeenAt': lastSeenAt.toUtc().toIso8601String(),
'occurrenceCount': occurrenceCount,
'sourceApps': sourceApps,
'evidenceMessageIds': evidenceMessageIds,
'evidenceSnippets': evidenceSnippets,
};
}
factory PersonPreferenceSignal.fromJson(Map<String, dynamic> json) {
final String polarityName =
json['polarity'] as String? ?? PreferenceSignalPolarity.neutral.name;
final String statusName =
json['status'] as String? ?? PreferenceSignalStatus.inferred.name;
return PersonPreferenceSignal(
id: json['id'] as String,
personId: json['personId'] as String,
key: json['key'] as String,
label: json['label'] as String? ?? '',
category: json['category'] as String? ?? 'general',
polarity: PreferenceSignalPolarity.values.firstWhere(
(PreferenceSignalPolarity item) => item.name == polarityName,
orElse: () => PreferenceSignalPolarity.neutral,
),
confidence: (json['confidence'] as num?)?.toDouble() ?? 0,
status: PreferenceSignalStatus.values.firstWhere(
(PreferenceSignalStatus item) => item.name == statusName,
orElse: () => PreferenceSignalStatus.inferred,
),
firstSeenAt: DateTime.parse(
json['firstSeenAt'] as String? ??
DateTime.now().toUtc().toIso8601String(),
).toLocal(),
lastSeenAt: DateTime.parse(
json['lastSeenAt'] as String? ??
DateTime.now().toUtc().toIso8601String(),
).toLocal(),
occurrenceCount: (json['occurrenceCount'] as num?)?.toInt() ?? 1,
sourceApps: (json['sourceApps'] as List<dynamic>? ?? <dynamic>[])
.map((dynamic item) => '$item')
.toList(growable: false),
evidenceMessageIds:
(json['evidenceMessageIds'] as List<dynamic>? ?? <dynamic>[])
.map((dynamic item) => '$item')
.toList(growable: false),
evidenceSnippets:
(json['evidenceSnippets'] as List<dynamic>? ?? <dynamic>[])
.map((dynamic item) => '$item')
.toList(growable: false),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
# People Presentation
This folder holds the people workspace UI.
`people_view.dart` is now the composition root for the slice, with the heavy UI
split into library parts:
- `people_view_list.dart`: list, filters, empty states, and person cards.
- `people_view_detail.dart`: detail workspace, insights, and inline editors.
- `people_view_dialogs.dart`: modal editors and quick-capture flows.
- `providers/people_ui_state.dart`: search, selection, and sorting state.
Add new people UI inside this folder. Do not move slice-specific widgets back
into shared horizontal folders unless they are genuinely cross-feature.
@@ -0,0 +1,627 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/local/local_repository.dart';
import 'package:relationship_saver/features/people/presentation/providers/people_ui_state.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart';
part 'people_view_detail.dart';
part 'people_view_dialogs.dart';
part 'people_view_list.dart';
enum _PersonQuickAction { capture, note, idea, reminder }
class PeopleView extends ConsumerWidget {
const PeopleView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final AsyncValue<LocalDataState> localData = ref.watch(
localRepositoryProvider,
);
final String searchQuery = ref.watch(peopleSearchQueryProvider);
final String? relationshipFilter = ref.watch(
peopleRelationshipFilterProvider,
);
final PeopleSortOption sortOption = ref.watch(peopleSortOptionProvider);
return localData.when(
data: (LocalDataState data) {
final List<PersonProfile> allPeople = data.people;
if (allPeople.isEmpty) {
return _EmptyPeopleView(onAdd: () => _handleAddPerson(context, ref));
}
final List<PersonProfile> people = _applyPeopleFilters(
allPeople,
searchQuery: searchQuery,
relationshipFilter: relationshipFilter,
sortOption: sortOption,
);
final List<String> relationshipOptions = _relationshipOptions(
allPeople,
);
if (people.isEmpty) {
return _FilteredPeopleEmptyView(
onClear: () {
ref.read(peopleSearchQueryProvider.notifier).set('');
ref.read(peopleRelationshipFilterProvider.notifier).set(null);
},
);
}
final String selectedId =
ref.watch(selectedPersonIdProvider) ?? people.first.id;
final PersonProfile selected = people.firstWhere(
(PersonProfile person) => person.id == selectedId,
orElse: () => people.first,
);
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool isCompact = constraints.maxWidth < 860;
if (isCompact) {
return _buildCompactLayout(
context,
ref,
data,
people,
selected,
relationshipOptions,
);
}
return _buildWideLayout(
context,
ref,
data,
people,
selected,
relationshipOptions,
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (Object error, StackTrace stackTrace) {
return const Center(child: Text('Unable to load people'));
},
);
}
List<PersonProfile> _applyPeopleFilters(
List<PersonProfile> people, {
required String searchQuery,
required String? relationshipFilter,
required PeopleSortOption sortOption,
}) {
final String query = searchQuery.trim().toLowerCase();
final String? relationship = relationshipFilter?.trim().toLowerCase();
final List<PersonProfile> filtered = people
.where((PersonProfile person) {
if (relationship != null &&
relationship.isNotEmpty &&
person.relationship.trim().toLowerCase() != relationship) {
return false;
}
if (query.isEmpty) {
return true;
}
final String haystack = <String>[
person.name,
...person.aliases,
person.relationship,
person.location ?? '',
person.notes,
...person.tags,
].join(' ').toLowerCase();
return haystack.contains(query);
})
.toList(growable: true);
filtered.sort((PersonProfile a, PersonProfile b) {
switch (sortOption) {
case PeopleSortOption.affinity:
final int score = b.affinityScore.compareTo(a.affinityScore);
if (score != 0) {
return score;
}
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
case PeopleSortOption.nextMoment:
final int next = a.nextMoment.compareTo(b.nextMoment);
if (next != 0) {
return next;
}
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
case PeopleSortOption.alphabetical:
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
}
});
return filtered;
}
List<String> _relationshipOptions(List<PersonProfile> people) {
final Set<String> seen = <String>{};
final List<String> values = <String>[];
for (final PersonProfile person in people) {
final String relationship = person.relationship.trim();
if (relationship.isEmpty) {
continue;
}
final String key = relationship.toLowerCase();
if (seen.add(key)) {
values.add(relationship);
}
}
values.sort(
(String a, String b) => a.toLowerCase().compareTo(b.toLowerCase()),
);
return values;
}
Future<void> _handleAddPerson(BuildContext context, WidgetRef ref) async {
final List<PersonProfile> existingPeople =
ref.read(localRepositoryProvider).asData?.value.people ??
const <PersonProfile>[];
final _PersonDraft? draft = await _showPersonEditor(
context,
title: 'Add Person',
existingPeople: existingPeople,
);
if (draft == null) {
return;
}
await ref
.read(localRepositoryProvider.notifier)
.addPerson(
name: draft.name,
relationship: draft.relationship,
notes: draft.notes,
tags: draft.tags,
location: draft.location,
aliases: draft.aliases,
);
final LocalDataState? latest = ref
.read(localRepositoryProvider)
.asData
?.value;
if (latest == null) {
return;
}
for (final PersonProfile person in latest.people) {
final bool locationMatches =
(person.location ?? '').trim() == (draft.location ?? '').trim();
if (person.name == draft.name &&
person.relationship == draft.relationship &&
locationMatches) {
ref.read(selectedPersonIdProvider.notifier).select(person.id);
break;
}
}
}
Future<void> _handleEditPerson(
BuildContext context,
WidgetRef ref,
PersonProfile person,
) async {
final List<PersonProfile> existingPeople =
ref.read(localRepositoryProvider).asData?.value.people ??
const <PersonProfile>[];
final _PersonDraft? draft = await _showPersonEditor(
context,
title: 'Edit Person',
initial: _PersonDraft(
name: person.name,
relationship: person.relationship,
notes: person.notes,
tags: person.tags,
aliases: person.aliases,
location: person.location,
),
existingPeople: existingPeople,
editingPersonId: person.id,
);
if (draft == null) {
return;
}
await ref
.read(localRepositoryProvider.notifier)
.updatePerson(
person.copyWith(
name: draft.name,
relationship: draft.relationship,
notes: draft.notes,
tags: draft.tags,
aliases: draft.aliases,
location: draft.location,
),
);
}
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();
}
Widget _buildWideLayout(
BuildContext context,
WidgetRef ref,
LocalDataState data,
List<PersonProfile> people,
PersonProfile selected,
List<String> relationshipOptions,
) {
return Padding(
padding: const EdgeInsets.fromLTRB(28, 24, 28, 28),
child: Row(
children: <Widget>[
Expanded(
flex: 5,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_PeopleHeader(
onAdd: () => _handleAddPerson(context, ref),
onMerge: () => _handleMergePeople(context, ref, people),
compact: false,
),
const SizedBox(height: 12),
_PeopleListControls(
compact: false,
relationshipOptions: relationshipOptions,
),
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];
return _PersonListItem(
person: person,
selectedId: selected.id,
compact: false,
onSelect: () {
ref
.read(selectedPersonIdProvider.notifier)
.select(person.id);
},
);
},
),
),
],
),
),
const SizedBox(width: 18),
Expanded(
flex: 6,
child: FrostedCard(
child: SingleChildScrollView(
child: _PersonDetail(
person: selected,
data: data,
onEdit: () => _handleEditPerson(context, ref, selected),
onDelete: () =>
_handleDeletePerson(context, ref, selected.id),
onQuickAction: (_PersonQuickAction action) =>
_handleQuickAction(context, ref, selected, action),
),
),
),
),
],
),
);
}
Widget _buildCompactLayout(
BuildContext context,
WidgetRef ref,
LocalDataState data,
List<PersonProfile> people,
PersonProfile selected,
List<String> relationshipOptions,
) {
return Padding(
padding: const EdgeInsets.fromLTRB(18, 16, 18, 20),
child: ListView(
children: <Widget>[
_PeopleHeader(
onAdd: () => _handleAddPerson(context, ref),
onMerge: () => _handleMergePeople(context, ref, people),
compact: true,
),
const SizedBox(height: 10),
_PeopleListControls(
compact: true,
relationshipOptions: relationshipOptions,
),
const SizedBox(height: 14),
FrostedCard(
child: _PersonDetail(
person: selected,
data: data,
compact: true,
onEdit: () => _handleEditPerson(context, ref, selected),
onDelete: () => _handleDeletePerson(context, ref, selected.id),
onQuickAction: (_PersonQuickAction action) =>
_handleQuickAction(context, ref, selected, action),
),
),
const SizedBox(height: 14),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Row(
children: <Widget>[
Text(
'All profiles',
style: Theme.of(context).textTheme.titleMedium,
),
const Spacer(),
Text(
'${people.length}',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: AppTheme.textSecondary,
),
),
],
),
),
const SizedBox(height: 10),
...people.map(
(PersonProfile person) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _PersonListItem(
person: person,
selectedId: selected.id,
compact: true,
onSelect: () {
ref.read(selectedPersonIdProvider.notifier).select(person.id);
},
),
),
),
],
),
);
}
Future<void> _handleQuickAction(
BuildContext context,
WidgetRef ref,
PersonProfile person,
_PersonQuickAction action,
) async {
switch (action) {
case _PersonQuickAction.capture:
final String? summary = await _showQuickTextCaptureDialog(
context,
title: 'Add Capture',
hintText: 'What happened in this interaction?',
);
if (summary == null) {
return;
}
await ref
.read(localRepositoryProvider.notifier)
.addMoment(personId: person.id, summary: summary, type: 'capture');
return;
case _PersonQuickAction.note:
final String? note = await _showQuickTextCaptureDialog(
context,
title: 'Add Note',
hintText: 'Write a quick context note...',
);
if (note == null) {
return;
}
await ref
.read(localRepositoryProvider.notifier)
.addMoment(personId: person.id, summary: note, type: 'note');
return;
case _PersonQuickAction.idea:
final _QuickIdeaDraft? idea = await showDialog<_QuickIdeaDraft>(
context: context,
builder: (BuildContext context) => const _QuickIdeaDialog(),
);
if (idea == null) {
return;
}
await ref
.read(localRepositoryProvider.notifier)
.addIdea(
type: idea.type,
title: idea.title,
details: idea.details,
personId: person.id,
);
return;
case _PersonQuickAction.reminder:
final _QuickReminderDraft? reminder =
await showDialog<_QuickReminderDraft>(
context: context,
builder: (BuildContext context) => const _QuickReminderDialog(),
);
if (reminder == null) {
return;
}
await ref
.read(localRepositoryProvider.notifier)
.addReminder(
title: reminder.title,
cadence: reminder.cadence,
nextAt: reminder.nextAt,
personId: person.id,
);
return;
}
}
Future<void> _handleMergePeople(
BuildContext context,
WidgetRef ref,
List<PersonProfile> people,
) async {
if (people.length < 2) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Need at least two profiles to merge.')),
);
return;
}
final List<PersonProfile> duplicateCandidates = _duplicateCandidates(
people,
);
if (duplicateCandidates.length < 2) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'No duplicate-name profiles found. Edit names first if needed.',
),
),
);
return;
}
final _MergePeopleDraft? draft = await showDialog<_MergePeopleDraft>(
context: context,
builder: (BuildContext context) =>
_MergePeopleDialog(people: duplicateCandidates),
);
if (draft == null) {
return;
}
await ref
.read(localRepositoryProvider.notifier)
.mergePersonProfiles(
sourcePersonId: draft.sourcePersonId,
targetPersonId: draft.targetPersonId,
);
ref.read(selectedPersonIdProvider.notifier).select(draft.targetPersonId);
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Profiles merged successfully.')),
);
}
List<PersonProfile> _duplicateCandidates(List<PersonProfile> people) {
final Map<String, List<PersonProfile>> grouped =
<String, List<PersonProfile>>{};
for (final PersonProfile person in people) {
final String key = _normalizeNameKey(person.name);
if (key.isEmpty) {
continue;
}
grouped.putIfAbsent(key, () => <PersonProfile>[]).add(person);
}
final List<PersonProfile> result = <PersonProfile>[];
for (final List<PersonProfile> group in grouped.values) {
if (group.length > 1) {
result.addAll(group);
}
}
return result;
}
String _normalizeNameKey(String value) {
return value
.trim()
.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9]+'), ' ')
.replaceAll(RegExp(r'\s+'), ' ');
}
Future<_PersonDraft?> _showPersonEditor(
BuildContext context, {
required String title,
_PersonDraft? initial,
List<PersonProfile> existingPeople = const <PersonProfile>[],
String? editingPersonId,
}) {
final bool compact = MediaQuery.sizeOf(context).width < 720;
if (compact) {
return showModalBottomSheet<_PersonDraft>(
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
builder: (BuildContext context) {
return _PersonEditorBottomSheet(
title: title,
initial: initial,
existingPeople: existingPeople,
editingPersonId: editingPersonId,
);
},
);
}
return showDialog<_PersonDraft>(
context: context,
builder: (BuildContext context) {
return _PersonEditorDialog(
title: title,
initial: initial,
existingPeople: existingPeople,
editingPersonId: editingPersonId,
);
},
);
}
Future<String?> _showQuickTextCaptureDialog(
BuildContext context, {
required String title,
required String hintText,
}) async {
return showDialog<String>(
context: context,
builder: (BuildContext context) {
return _QuickTextCaptureDialog(title: title, hintText: hintText);
},
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,528 @@
part of 'people_view.dart';
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 _FilteredPeopleEmptyView extends StatelessWidget {
const _FilteredPeopleEmptyView({required this.onClear});
final VoidCallback onClear;
@override
Widget build(BuildContext context) {
return Center(
child: FrostedCard(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
const Icon(
Icons.search_off_rounded,
size: 34,
color: AppTheme.textSecondary,
),
const SizedBox(height: 10),
Text(
'No matching people',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 6),
Text(
'Try another search, relationship filter, or clear the current filters.',
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: onClear,
icon: const Icon(Icons.restart_alt_rounded),
label: const Text('Clear Filters'),
),
],
),
),
);
}
}
class _PeopleListControls extends ConsumerWidget {
const _PeopleListControls({
required this.compact,
required this.relationshipOptions,
});
final bool compact;
final List<String> relationshipOptions;
@override
Widget build(BuildContext context, WidgetRef ref) {
final String query = ref.watch(peopleSearchQueryProvider);
final String? selectedRelationship = ref.watch(
peopleRelationshipFilterProvider,
);
final PeopleSortOption sortOption = ref.watch(peopleSortOptionProvider);
return FrostedCard(
padding: EdgeInsets.all(compact ? 12 : 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: TextFormField(
key: ValueKey<String>('people-search-$query'),
initialValue: query,
onChanged: (String value) {
ref.read(peopleSearchQueryProvider.notifier).set(value);
},
decoration: const InputDecoration(
hintText: 'Search name, tags, notes, location',
prefixIcon: Icon(Icons.search_rounded),
isDense: true,
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
PopupMenuButton<PeopleSortOption>(
tooltip: 'Sort',
onSelected: (PeopleSortOption value) {
ref.read(peopleSortOptionProvider.notifier).set(value);
},
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<PeopleSortOption>>[
const PopupMenuItem<PeopleSortOption>(
value: PeopleSortOption.affinity,
child: Text('Sort by affinity'),
),
const PopupMenuItem<PeopleSortOption>(
value: PeopleSortOption.nextMoment,
child: Text('Sort by next moment'),
),
const PopupMenuItem<PeopleSortOption>(
value: PeopleSortOption.alphabetical,
child: Text('Sort A-Z'),
),
],
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 10,
),
decoration: BoxDecoration(
color: const Color(0xFFF3F8FB),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
const Icon(Icons.sort_rounded, size: 18),
if (!compact) ...<Widget>[
const SizedBox(width: 6),
Text(switch (sortOption) {
PeopleSortOption.affinity => 'Affinity',
PeopleSortOption.nextMoment => 'Next',
PeopleSortOption.alphabetical => 'A-Z',
}),
],
],
),
),
),
],
),
const SizedBox(height: 10),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: <Widget>[
_FilterChipButton(
label: 'All',
selected: selectedRelationship == null,
onTap: () {
ref
.read(peopleRelationshipFilterProvider.notifier)
.set(null);
},
),
...relationshipOptions.map(
(String relationship) => Padding(
padding: const EdgeInsets.only(left: 8),
child: _FilterChipButton(
label: relationship,
selected:
selectedRelationship?.toLowerCase() ==
relationship.toLowerCase(),
onTap: () {
final PeopleRelationshipFilterNotifier controller = ref
.read(peopleRelationshipFilterProvider.notifier);
final bool isSelected =
selectedRelationship?.toLowerCase() ==
relationship.toLowerCase();
controller.set(isSelected ? null : relationship);
},
),
),
),
],
),
),
],
),
);
}
}
class _FilterChipButton extends StatelessWidget {
const _FilterChipButton({
required this.label,
required this.selected,
required this.onTap,
});
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(99),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: selected ? const Color(0xFFEAF7FB) : const Color(0xFFF4F8FB),
borderRadius: BorderRadius.circular(99),
border: Border.all(
color: selected
? const Color(0xFFB8E4EE)
: Colors.white.withValues(alpha: 0.8),
),
),
child: Text(
label,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: selected ? AppTheme.primary : AppTheme.textSecondary,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
),
),
),
);
}
}
class _PeopleHeader extends StatelessWidget {
const _PeopleHeader({
required this.onAdd,
required this.onMerge,
required this.compact,
});
final VoidCallback onAdd;
final VoidCallback onMerge;
final bool compact;
@override
Widget build(BuildContext context) {
final TextTheme textTheme = Theme.of(context).textTheme;
if (compact) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text('People', style: textTheme.headlineMedium),
const SizedBox(height: 6),
Text(
'Track what matters to each relationship and follow through.',
style: textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: onAdd,
icon: const Icon(Icons.person_add_alt_1_rounded),
label: const Text('Add person'),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: onMerge,
icon: const Icon(Icons.merge_type_rounded),
label: const Text('Merge duplicates'),
),
],
);
}
return Row(
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text('People', style: textTheme.headlineMedium),
const SizedBox(height: 6),
Text(
'Track what matters to each relationship and follow through.',
style: textTheme.bodyLarge?.copyWith(
color: AppTheme.textSecondary,
),
),
],
),
),
FilledButton.icon(
onPressed: onAdd,
icon: const Icon(Icons.person_add_alt_1_rounded),
label: const Text('Add'),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: onMerge,
icon: const Icon(Icons.merge_type_rounded),
label: const Text('Merge'),
),
],
);
}
}
class _PersonListItem extends StatelessWidget {
const _PersonListItem({
required this.person,
required this.selectedId,
required this.compact,
required this.onSelect,
});
final PersonProfile person;
final String selectedId;
final bool compact;
final VoidCallback onSelect;
@override
Widget build(BuildContext context) {
final bool isSelected = person.id == selectedId;
return InkWell(
onTap: onSelect,
borderRadius: BorderRadius.circular(20),
child: FrostedCard(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
person.relationship,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppTheme.textSecondary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 8),
_ScorePill(score: person.affinityScore),
if (isSelected)
const Padding(
padding: EdgeInsets.only(left: 10),
child: Icon(Icons.check_circle, color: Color(0xFF1AB6C8)),
),
],
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
_InlineMetaPill(
icon: Icons.schedule_rounded,
label: _dateTimeLabel(person.nextMoment),
),
if ((person.location ?? '').trim().isNotEmpty)
_InlineMetaPill(
icon: Icons.location_on_outlined,
label: person.location!.trim(),
),
...person.tags
.take(compact ? 2 : 3)
.map(
(String tag) => _InlineMetaPill(
icon: Icons.sell_outlined,
label: tag,
),
),
],
),
],
),
),
);
}
}
class _InlineMetaPill extends StatelessWidget {
const _InlineMetaPill({required this.icon, required this.label});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
decoration: BoxDecoration(
color: const Color(0xFFF1F7FA),
borderRadius: BorderRadius.circular(99),
border: Border.all(color: Colors.white),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Icon(icon, size: 14, color: AppTheme.textSecondary),
const SizedBox(width: 6),
Flexible(
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary),
),
),
],
),
);
}
}
class _AvatarSeed extends StatelessWidget {
const _AvatarSeed({required this.name, this.size = 48});
final String name;
final double size;
@override
Widget build(BuildContext context) {
final String initials = name
.split(' ')
.where((String part) => part.isNotEmpty)
.take(2)
.map((String part) => part[0].toUpperCase())
.join();
return Container(
width: size,
height: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(size / 2),
gradient: const LinearGradient(
colors: <Color>[Color(0xFF1AB6C8), Color(0xFF2274E0)],
),
),
alignment: Alignment.center,
child: Text(
initials,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w700,
),
),
);
}
}
class _ScorePill extends StatelessWidget {
const _ScorePill({required this.score});
final int score;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFFEEF7F3),
borderRadius: BorderRadius.circular(99),
),
child: Text(
'$score%',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: const Color(0xFF1D9C66),
fontWeight: FontWeight.w700,
),
),
);
}
}
String _dateTimeLabel(DateTime value) {
final DateTime local = value.toLocal();
final String month = local.month.toString().padLeft(2, '0');
final String day = local.day.toString().padLeft(2, '0');
final String hour = local.hour.toString().padLeft(2, '0');
final String minute = local.minute.toString().padLeft(2, '0');
return '$month/$day $hour:$minute';
}
String _shortDateTimeLabel(DateTime value) {
final DateTime local = value.toLocal();
final String year = local.year.toString();
final String month = local.month.toString().padLeft(2, '0');
final String day = local.day.toString().padLeft(2, '0');
final String hour = local.hour.toString().padLeft(2, '0');
final String minute = local.minute.toString().padLeft(2, '0');
return '$year-$month-$day $hour:$minute';
}
@@ -0,0 +1,4 @@
# People UI State
This folder contains lightweight Riverpod state used only by the people
workspace, such as search text, selected profile, and sort/filter controls.
@@ -0,0 +1,70 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// Current sort mode for the people list.
enum PeopleSortOption { affinity, nextMoment, alphabetical }
/// Selected profile in the people workspace.
class SelectedPersonIdNotifier extends Notifier<String?> {
@override
String? build() => null;
void select(String id) {
state = id;
}
void clear() {
state = null;
}
}
final NotifierProvider<SelectedPersonIdNotifier, String?>
selectedPersonIdProvider = NotifierProvider<SelectedPersonIdNotifier, String?>(
SelectedPersonIdNotifier.new,
);
/// Search query applied to the people list.
class PeopleSearchQueryNotifier extends Notifier<String> {
@override
String build() => '';
void set(String value) {
state = value;
}
}
final NotifierProvider<PeopleSearchQueryNotifier, String>
peopleSearchQueryProvider = NotifierProvider<PeopleSearchQueryNotifier, String>(
PeopleSearchQueryNotifier.new,
);
/// Relationship filter applied to the people list.
class PeopleRelationshipFilterNotifier extends Notifier<String?> {
@override
String? build() => null;
void set(String? value) {
state = value;
}
}
final NotifierProvider<PeopleRelationshipFilterNotifier, String?>
peopleRelationshipFilterProvider =
NotifierProvider<PeopleRelationshipFilterNotifier, String?>(
PeopleRelationshipFilterNotifier.new,
);
/// Selected sort mode for the people list.
class PeopleSortOptionNotifier extends Notifier<PeopleSortOption> {
@override
PeopleSortOption build() => PeopleSortOption.affinity;
void set(PeopleSortOption value) {
state = value;
}
}
final NotifierProvider<PeopleSortOptionNotifier, PeopleSortOption>
peopleSortOptionProvider =
NotifierProvider<PeopleSortOptionNotifier, PeopleSortOption>(
PeopleSortOptionNotifier.new,
);
+9
View File
@@ -0,0 +1,9 @@
# Reminders Slice
This slice owns reminder rules and local scheduling behavior.
- `domain/reminder_models.dart`: reminder cadence and rule models.
- `presentation/reminders_view.dart`: reminder management screen.
- `application/`: notification listeners, local scheduling, and providers.
Work here when you change reminder UX or local notification behavior.
@@ -0,0 +1,4 @@
# Reminders Application
Reminder scheduling orchestration and provider wiring live here. Start here when
the behavior of reminder execution or platform scheduling needs to change.
@@ -0,0 +1,18 @@
import 'dart:async';
/// Broadcasts reminder notification tap intents to the UI layer.
class ReminderNotificationIntentBus {
final StreamController<String> _controller =
StreamController<String>.broadcast();
Stream<String> get intents => _controller.stream;
void emitTap(String reminderId) {
if (reminderId.trim().isEmpty) {
return;
}
_controller.add(reminderId.trim());
}
Future<void> close() => _controller.close();
}
@@ -0,0 +1,67 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/features/reminders/reminders_view.dart';
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_provider.dart';
/// Listens for reminder notification taps and opens reminder management UI.
class ReminderNotificationListener extends ConsumerStatefulWidget {
const ReminderNotificationListener({required this.child, super.key});
final Widget child;
@override
ConsumerState<ReminderNotificationListener> createState() =>
_ReminderNotificationListenerState();
}
class _ReminderNotificationListenerState
extends ConsumerState<ReminderNotificationListener> {
StreamSubscription<String>? _subscription;
@override
void initState() {
super.initState();
_subscription = ref
.read(reminderNotificationIntentBusProvider)
.intents
.listen(_handleIntent);
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
Future<void> _handleIntent(String reminderId) async {
if (!mounted) {
return;
}
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (BuildContext context) =>
_ReminderNotificationScreen(reminderId: reminderId),
),
);
}
}
class _ReminderNotificationScreen extends StatelessWidget {
const _ReminderNotificationScreen({required this.reminderId});
final String reminderId;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Reminder $reminderId')),
body: const RemindersView(),
);
}
}
@@ -0,0 +1,27 @@
import 'package:relationship_saver/features/local/local_models.dart';
/// Schedules and reconciles reminder deliveries for current local state.
abstract class ReminderScheduler {
/// Reconciles platform schedules to match current enabled reminders.
Future<void> reconcile(List<ReminderRule> reminders);
/// Clears all platform-scheduled reminder jobs.
Future<void> clearAll();
/// Requests notification permissions where the platform requires them.
Future<bool> requestPermissions();
}
/// Default scheduler used until platform notifications are integrated.
class NoopReminderScheduler implements ReminderScheduler {
const NoopReminderScheduler();
@override
Future<void> clearAll() async {}
@override
Future<void> reconcile(List<ReminderRule> reminders) async {}
@override
Future<bool> requestPermissions() async => true;
}
@@ -0,0 +1,240 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/reminders/scheduling/reminder_notification_intent_bus.dart';
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler.dart';
import 'package:timezone/timezone.dart' as tz;
const String _channelId = 'relationship_saver_reminders';
const String _channelName = 'Relationship Reminders';
const String _channelDescription =
'Reminders for relationship plans and follow-ups';
/// Schedules reminders with local notifications where platform support exists.
class LocalNotificationsReminderScheduler implements ReminderScheduler {
LocalNotificationsReminderScheduler({
ReminderNotificationsDriver? driver,
DateTime Function()? now,
ReminderNotificationIntentBus? intentBus,
}) : _driver =
driver ??
FlutterReminderNotificationsDriver(onTapPayload: intentBus?.emitTap),
_now = now ?? DateTime.now;
final ReminderNotificationsDriver _driver;
final DateTime Function() _now;
@override
Future<void> clearAll() async {
if (!_supportsSchedulingPlatform()) {
return;
}
await _driver.ensureInitialized();
await _driver.cancelAll();
}
@override
Future<void> reconcile(List<ReminderRule> reminders) async {
if (!_supportsSchedulingPlatform()) {
return;
}
await _driver.ensureInitialized();
await _driver.cancelAll();
final DateTime now = _now();
final List<ReminderRule> enabled = reminders
.where((ReminderRule reminder) => reminder.enabled)
.toList(growable: false);
for (final ReminderRule reminder in enabled) {
final DateTimeComponents? repeating = _repeatComponents(reminder.cadence);
if (repeating == null && reminder.nextAt.isBefore(now)) {
continue;
}
await _driver.schedule(
id: _notificationId(reminder.id),
title: reminder.title,
body: _buildBody(reminder),
scheduledAt: reminder.nextAt,
repeatComponents: repeating,
payload: reminder.id,
);
}
}
@override
Future<bool> requestPermissions() async {
if (!_supportsSchedulingPlatform()) {
return false;
}
await _driver.ensureInitialized();
return _driver.requestPermissions();
}
DateTimeComponents? _repeatComponents(ReminderCadence cadence) {
return switch (cadence) {
ReminderCadence.daily => DateTimeComponents.time,
ReminderCadence.weekly => DateTimeComponents.dayOfWeekAndTime,
ReminderCadence.monthly => DateTimeComponents.dayOfMonthAndTime,
};
}
String _buildBody(ReminderRule reminder) {
final String frequency = switch (reminder.cadence) {
ReminderCadence.daily => 'daily',
ReminderCadence.weekly => 'weekly',
ReminderCadence.monthly => 'monthly',
};
return 'Your $frequency reminder is due now.';
}
bool _supportsSchedulingPlatform() {
if (kIsWeb) {
return false;
}
return defaultTargetPlatform != TargetPlatform.linux;
}
int _notificationId(String raw) {
int hash = 0;
for (final int unit in raw.codeUnits) {
hash = ((hash * 31) + unit) & 0x7fffffff;
}
return hash;
}
}
/// Driver wrapper around the notification plugin for easier testing.
abstract class ReminderNotificationsDriver {
Future<void> ensureInitialized();
Future<void> cancelAll();
Future<void> schedule({
required int id,
required String title,
required String body,
required DateTime scheduledAt,
required DateTimeComponents? repeatComponents,
required String payload,
});
Future<bool> requestPermissions();
}
class FlutterReminderNotificationsDriver
implements ReminderNotificationsDriver {
FlutterReminderNotificationsDriver({
FlutterLocalNotificationsPlugin? plugin,
void Function(String payload)? onTapPayload,
}) : _plugin = plugin ?? FlutterLocalNotificationsPlugin(),
_onTapPayload = onTapPayload;
final FlutterLocalNotificationsPlugin _plugin;
final void Function(String payload)? _onTapPayload;
Future<void>? _initializeFuture;
@override
Future<void> ensureInitialized() {
return _initializeFuture ??= _initialize();
}
@override
Future<void> cancelAll() async {
await _plugin.cancelAll();
}
@override
Future<void> schedule({
required int id,
required String title,
required String body,
required DateTime scheduledAt,
required DateTimeComponents? repeatComponents,
required String payload,
}) async {
await _plugin.zonedSchedule(
id: id,
title: title,
body: body,
scheduledDate: tz.TZDateTime.from(scheduledAt.toUtc(), tz.UTC),
notificationDetails: _notificationDetails,
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
matchDateTimeComponents: repeatComponents,
payload: payload,
);
}
@override
Future<bool> requestPermissions() async {
if (defaultTargetPlatform == TargetPlatform.android) {
return await _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.requestNotificationsPermission() ??
false;
}
if (defaultTargetPlatform == TargetPlatform.iOS) {
return await _plugin
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin
>()
?.requestPermissions(alert: true, badge: true, sound: true) ??
false;
}
if (defaultTargetPlatform == TargetPlatform.macOS) {
return await _plugin
.resolvePlatformSpecificImplementation<
MacOSFlutterLocalNotificationsPlugin
>()
?.requestPermissions(alert: true, badge: true, sound: true) ??
false;
}
return true;
}
Future<void> _initialize() async {
await _plugin.initialize(
settings: const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
iOS: DarwinInitializationSettings(),
macOS: DarwinInitializationSettings(),
linux: LinuxInitializationSettings(defaultActionName: 'Open reminders'),
windows: WindowsInitializationSettings(
appName: 'Relationship Saver',
appUserModelId: 'com.relationshipsaver.app',
guid: 'ee23c0af-5898-4d96-b599-cf2d7d0d37d7',
),
),
onDidReceiveNotificationResponse: (NotificationResponse response) {
final String? payload = response.payload;
if (payload == null || payload.isEmpty) {
return;
}
_onTapPayload?.call(payload);
},
);
}
NotificationDetails get _notificationDetails {
return const NotificationDetails(
android: AndroidNotificationDetails(
_channelId,
_channelName,
channelDescription: _channelDescription,
importance: Importance.high,
priority: Priority.high,
),
iOS: DarwinNotificationDetails(),
macOS: DarwinNotificationDetails(),
linux: LinuxNotificationDetails(),
windows: WindowsNotificationDetails(),
);
}
}
@@ -0,0 +1,30 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_config.dart';
import 'package:relationship_saver/features/reminders/scheduling/reminder_notification_intent_bus.dart';
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler.dart';
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_local_notifications.dart';
/// Broadcasts reminder notification tap intents to interested UI listeners.
final Provider<ReminderNotificationIntentBus>
reminderNotificationIntentBusProvider = Provider<ReminderNotificationIntentBus>(
(Ref ref) {
final ReminderNotificationIntentBus bus = ReminderNotificationIntentBus();
ref.onDispose(() {
unawaited(bus.close());
});
return bus;
},
);
/// Provides reminder delivery scheduler implementation.
final Provider<ReminderScheduler> reminderSchedulerProvider =
Provider<ReminderScheduler>((Ref ref) {
if (AppConfig.enableLocalNotifications) {
return LocalNotificationsReminderScheduler(
intentBus: ref.watch(reminderNotificationIntentBusProvider),
);
}
return const NoopReminderScheduler();
});
+4
View File
@@ -0,0 +1,4 @@
# Reminders Domain
Reminder models and cadence enums live here. Keep this layer free of platform
notification details so it stays easy to test and reuse.
@@ -0,0 +1,71 @@
// ignore_for_file: sort_constructors_first
import 'package:flutter/foundation.dart';
/// Reminder cadence supported by the local scheduling layer.
enum ReminderCadence { daily, weekly, monthly }
/// Reminder rule stored locally and optionally synced later.
@immutable
class ReminderRule {
const ReminderRule({
required this.id,
required this.title,
required this.cadence,
required this.nextAt,
this.personId,
this.enabled = true,
});
final String id;
final String? personId;
final String title;
final ReminderCadence cadence;
final DateTime nextAt;
final bool enabled;
ReminderRule copyWith({
String? id,
String? personId,
String? title,
ReminderCadence? cadence,
DateTime? nextAt,
bool? enabled,
}) {
return ReminderRule(
id: id ?? this.id,
personId: personId ?? this.personId,
title: title ?? this.title,
cadence: cadence ?? this.cadence,
nextAt: nextAt ?? this.nextAt,
enabled: enabled ?? this.enabled,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'personId': personId,
'title': title,
'cadence': cadence.name,
'nextAt': nextAt.toUtc().toIso8601String(),
'enabled': enabled,
};
}
factory ReminderRule.fromJson(Map<String, dynamic> json) {
final String cadenceName =
json['cadence'] as String? ?? ReminderCadence.weekly.name;
return ReminderRule(
id: json['id'] as String,
personId: json['personId'] as String?,
title: json['title'] as String,
cadence: ReminderCadence.values.firstWhere(
(ReminderCadence cadence) => cadence.name == cadenceName,
orElse: () => ReminderCadence.weekly,
),
nextAt: DateTime.parse(json['nextAt'] as String).toLocal(),
enabled: json['enabled'] as bool? ?? true,
);
}
}
@@ -0,0 +1,4 @@
# Reminders Presentation
Reminder list and editor UI belong here. Use this folder for reminder-facing
widgets, not for scheduling adapters or background execution.
@@ -0,0 +1,561 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/local/local_repository.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart';
class RemindersView extends ConsumerWidget {
const RemindersView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final AsyncValue<LocalDataState> localData = ref.watch(
localRepositoryProvider,
);
return localData.when(
data: (LocalDataState data) {
final List<ReminderRule> reminders = data.reminders;
final Map<String, PersonProfile> peopleById = <String, PersonProfile>{
for (final PersonProfile person in data.people) person.id: person,
};
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool compact = constraints.maxWidth < 760;
return Padding(
padding: EdgeInsets.fromLTRB(
compact ? 16 : 28,
compact ? 16 : 24,
compact ? 16 : 28,
compact ? 20 : 28,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (compact)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Reminders',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'Set recurring nudges so good intentions become habits.',
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: () =>
_addReminder(context, ref, data.people),
icon: const Icon(Icons.alarm_add_rounded),
label: const Text('Add Reminder'),
),
],
)
else
Row(
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Reminders',
style: Theme.of(
context,
).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'Set recurring nudges so good intentions become habits.',
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: AppTheme.textSecondary),
),
],
),
),
FilledButton.icon(
onPressed: () =>
_addReminder(context, ref, data.people),
icon: const Icon(Icons.alarm_add_rounded),
label: const Text('Add Reminder'),
),
],
),
const SizedBox(height: 16),
Expanded(
child: ListView.separated(
itemCount: reminders.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (BuildContext context, int index) {
final ReminderRule reminder = reminders[index];
final PersonProfile? person = reminder.personId == null
? null
: peopleById[reminder.personId!];
return FrostedCard(
child: compact
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: Text(
reminder.title,
style: Theme.of(
context,
).textTheme.titleMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
Switch(
value: reminder.enabled,
onChanged: (_) {
ref
.read(
localRepositoryProvider
.notifier,
)
.toggleReminderEnabled(
reminder.id,
);
},
),
],
),
Text(
'${_labelForCadence(reminder.cadence)}${_formatDateTime(reminder.nextAt)}',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 4),
Text(
person == null
? 'Unassigned'
: person.name,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: <Widget>[
OutlinedButton.icon(
onPressed: () => _editReminder(
context,
ref,
data.people,
reminder,
),
icon: const Icon(Icons.edit_rounded),
label: const Text('Edit'),
),
TextButton.icon(
onPressed: () {
ref
.read(
localRepositoryProvider
.notifier,
)
.deleteReminder(reminder.id);
},
icon: const Icon(
Icons.delete_outline_rounded,
),
label: const Text('Delete'),
),
],
),
],
)
: Row(
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: <Widget>[
Text(
reminder.title,
style: Theme.of(
context,
).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
'${_labelForCadence(reminder.cadence)}${_formatDateTime(reminder.nextAt)}',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 4),
Text(
person == null
? 'Unassigned'
: person.name,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: AppTheme.textSecondary,
),
),
],
),
),
Switch(
value: reminder.enabled,
onChanged: (_) {
ref
.read(
localRepositoryProvider.notifier,
)
.toggleReminderEnabled(reminder.id);
},
),
IconButton(
onPressed: () => _editReminder(
context,
ref,
data.people,
reminder,
),
icon: const Icon(Icons.edit_rounded),
tooltip: 'Edit',
),
IconButton(
onPressed: () {
ref
.read(
localRepositoryProvider.notifier,
)
.deleteReminder(reminder.id);
},
icon: const Icon(
Icons.delete_outline_rounded,
),
tooltip: 'Delete',
),
],
),
);
},
),
),
],
),
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (Object error, StackTrace stackTrace) {
return const Center(child: Text('Unable to load reminders'));
},
);
}
Future<void> _addReminder(
BuildContext context,
WidgetRef ref,
List<PersonProfile> people,
) async {
final _ReminderDraft? draft = await showDialog<_ReminderDraft>(
context: context,
builder: (BuildContext context) =>
_ReminderEditorDialog(title: 'Add Reminder', people: people),
);
if (draft == null) {
return;
}
await ref
.read(localRepositoryProvider.notifier)
.addReminder(
title: draft.title,
cadence: draft.cadence,
nextAt: draft.nextAt,
personId: draft.personId,
);
}
Future<void> _editReminder(
BuildContext context,
WidgetRef ref,
List<PersonProfile> people,
ReminderRule reminder,
) async {
final _ReminderDraft? draft = await showDialog<_ReminderDraft>(
context: context,
builder: (BuildContext context) => _ReminderEditorDialog(
title: 'Edit Reminder',
people: people,
initial: _ReminderDraft(
personId: reminder.personId,
title: reminder.title,
cadence: reminder.cadence,
nextAt: reminder.nextAt,
),
),
);
if (draft == null) {
return;
}
await ref
.read(localRepositoryProvider.notifier)
.updateReminder(
reminder.copyWith(
personId: draft.personId,
title: draft.title,
cadence: draft.cadence,
nextAt: draft.nextAt,
),
);
}
}
class _ReminderEditorDialog extends StatefulWidget {
const _ReminderEditorDialog({
required this.title,
required this.people,
this.initial,
});
final String title;
final List<PersonProfile> people;
final _ReminderDraft? initial;
@override
State<_ReminderEditorDialog> createState() => _ReminderEditorDialogState();
}
class _ReminderEditorDialogState extends State<_ReminderEditorDialog> {
late final TextEditingController _titleController;
late ReminderCadence _cadence;
late DateTime _nextAt;
String? _personId;
@override
void initState() {
super.initState();
_titleController = TextEditingController(text: widget.initial?.title ?? '');
_cadence = widget.initial?.cadence ?? ReminderCadence.weekly;
_nextAt =
widget.initial?.nextAt ?? DateTime.now().add(const Duration(days: 2));
_personId = widget.initial?.personId;
}
@override
void dispose() {
_titleController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.title),
content: SingleChildScrollView(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
TextField(
controller: _titleController,
decoration: const InputDecoration(labelText: 'Title'),
),
DropdownButtonFormField<ReminderCadence>(
initialValue: _cadence,
items: ReminderCadence.values
.map(
(ReminderCadence cadence) =>
DropdownMenuItem<ReminderCadence>(
value: cadence,
child: Text(_labelForCadence(cadence)),
),
)
.toList(growable: false),
onChanged: (ReminderCadence? value) {
if (value == null) {
return;
}
setState(() {
_cadence = value;
});
},
decoration: const InputDecoration(labelText: 'Cadence'),
),
DropdownButtonFormField<String?>(
initialValue: _personId,
items: <DropdownMenuItem<String?>>[
const DropdownMenuItem<String?>(
value: null,
child: Text('Unassigned'),
),
...widget.people.map(
(PersonProfile person) => DropdownMenuItem<String?>(
value: person.id,
child: Text(person.name),
),
),
],
onChanged: (String? value) {
setState(() {
_personId = value;
});
},
decoration: const InputDecoration(labelText: 'Person'),
),
const SizedBox(height: 12),
LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool compact = constraints.maxWidth < 360;
if (compact) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
_formatDateTime(_nextAt),
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _pickDateTime,
icon: const Icon(Icons.schedule_rounded),
label: const Text('Pick Time'),
),
],
);
}
return Row(
children: <Widget>[
Expanded(
child: Text(
_formatDateTime(_nextAt),
style: Theme.of(context).textTheme.bodyLarge,
),
),
OutlinedButton.icon(
onPressed: _pickDateTime,
icon: const Icon(Icons.schedule_rounded),
label: const Text('Pick Time'),
),
],
);
},
),
],
),
),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final String title = _titleController.text.trim();
if (title.isEmpty) {
return;
}
Navigator.of(context).pop(
_ReminderDraft(
personId: _personId,
title: title,
cadence: _cadence,
nextAt: _nextAt,
),
);
},
child: const Text('Save'),
),
],
);
}
Future<void> _pickDateTime() async {
final DateTime now = DateTime.now();
final DateTime? date = await showDatePicker(
context: context,
initialDate: _nextAt,
firstDate: now.subtract(const Duration(days: 1)),
lastDate: now.add(const Duration(days: 3650)),
);
if (date == null || !mounted) {
return;
}
final TimeOfDay? time = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(_nextAt),
);
if (time == null || !mounted) {
return;
}
setState(() {
_nextAt = DateTime(
date.year,
date.month,
date.day,
time.hour,
time.minute,
);
});
}
}
class _ReminderDraft {
const _ReminderDraft({
required this.personId,
required this.title,
required this.cadence,
required this.nextAt,
});
final String? personId;
final String title;
final ReminderCadence cadence;
final DateTime nextAt;
}
String _labelForCadence(ReminderCadence cadence) {
return switch (cadence) {
ReminderCadence.daily => 'Daily',
ReminderCadence.weekly => 'Weekly',
ReminderCadence.monthly => 'Monthly',
};
}
String _formatDateTime(DateTime value) {
final String month = value.month.toString().padLeft(2, '0');
final String day = value.day.toString().padLeft(2, '0');
final String hour = value.hour.toString().padLeft(2, '0');
final String minute = value.minute.toString().padLeft(2, '0');
return '$month/$day/${value.year} $hour:$minute';
}
+2 -561
View File
@@ -1,561 +1,2 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/local/local_repository.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart';
class RemindersView extends ConsumerWidget {
const RemindersView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final AsyncValue<LocalDataState> localData = ref.watch(
localRepositoryProvider,
);
return localData.when(
data: (LocalDataState data) {
final List<ReminderRule> reminders = data.reminders;
final Map<String, PersonProfile> peopleById = <String, PersonProfile>{
for (final PersonProfile person in data.people) person.id: person,
};
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool compact = constraints.maxWidth < 760;
return Padding(
padding: EdgeInsets.fromLTRB(
compact ? 16 : 28,
compact ? 16 : 24,
compact ? 16 : 28,
compact ? 20 : 28,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (compact)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Reminders',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'Set recurring nudges so good intentions become habits.',
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: () =>
_addReminder(context, ref, data.people),
icon: const Icon(Icons.alarm_add_rounded),
label: const Text('Add Reminder'),
),
],
)
else
Row(
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Reminders',
style: Theme.of(
context,
).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'Set recurring nudges so good intentions become habits.',
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: AppTheme.textSecondary),
),
],
),
),
FilledButton.icon(
onPressed: () =>
_addReminder(context, ref, data.people),
icon: const Icon(Icons.alarm_add_rounded),
label: const Text('Add Reminder'),
),
],
),
const SizedBox(height: 16),
Expanded(
child: ListView.separated(
itemCount: reminders.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (BuildContext context, int index) {
final ReminderRule reminder = reminders[index];
final PersonProfile? person = reminder.personId == null
? null
: peopleById[reminder.personId!];
return FrostedCard(
child: compact
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: Text(
reminder.title,
style: Theme.of(
context,
).textTheme.titleMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
Switch(
value: reminder.enabled,
onChanged: (_) {
ref
.read(
localRepositoryProvider
.notifier,
)
.toggleReminderEnabled(
reminder.id,
);
},
),
],
),
Text(
'${_labelForCadence(reminder.cadence)}${_formatDateTime(reminder.nextAt)}',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 4),
Text(
person == null
? 'Unassigned'
: person.name,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: <Widget>[
OutlinedButton.icon(
onPressed: () => _editReminder(
context,
ref,
data.people,
reminder,
),
icon: const Icon(Icons.edit_rounded),
label: const Text('Edit'),
),
TextButton.icon(
onPressed: () {
ref
.read(
localRepositoryProvider
.notifier,
)
.deleteReminder(reminder.id);
},
icon: const Icon(
Icons.delete_outline_rounded,
),
label: const Text('Delete'),
),
],
),
],
)
: Row(
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: <Widget>[
Text(
reminder.title,
style: Theme.of(
context,
).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
'${_labelForCadence(reminder.cadence)}${_formatDateTime(reminder.nextAt)}',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 4),
Text(
person == null
? 'Unassigned'
: person.name,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: AppTheme.textSecondary,
),
),
],
),
),
Switch(
value: reminder.enabled,
onChanged: (_) {
ref
.read(
localRepositoryProvider.notifier,
)
.toggleReminderEnabled(reminder.id);
},
),
IconButton(
onPressed: () => _editReminder(
context,
ref,
data.people,
reminder,
),
icon: const Icon(Icons.edit_rounded),
tooltip: 'Edit',
),
IconButton(
onPressed: () {
ref
.read(
localRepositoryProvider.notifier,
)
.deleteReminder(reminder.id);
},
icon: const Icon(
Icons.delete_outline_rounded,
),
tooltip: 'Delete',
),
],
),
);
},
),
),
],
),
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (Object error, StackTrace stackTrace) {
return const Center(child: Text('Unable to load reminders'));
},
);
}
Future<void> _addReminder(
BuildContext context,
WidgetRef ref,
List<PersonProfile> people,
) async {
final _ReminderDraft? draft = await showDialog<_ReminderDraft>(
context: context,
builder: (BuildContext context) =>
_ReminderEditorDialog(title: 'Add Reminder', people: people),
);
if (draft == null) {
return;
}
await ref
.read(localRepositoryProvider.notifier)
.addReminder(
title: draft.title,
cadence: draft.cadence,
nextAt: draft.nextAt,
personId: draft.personId,
);
}
Future<void> _editReminder(
BuildContext context,
WidgetRef ref,
List<PersonProfile> people,
ReminderRule reminder,
) async {
final _ReminderDraft? draft = await showDialog<_ReminderDraft>(
context: context,
builder: (BuildContext context) => _ReminderEditorDialog(
title: 'Edit Reminder',
people: people,
initial: _ReminderDraft(
personId: reminder.personId,
title: reminder.title,
cadence: reminder.cadence,
nextAt: reminder.nextAt,
),
),
);
if (draft == null) {
return;
}
await ref
.read(localRepositoryProvider.notifier)
.updateReminder(
reminder.copyWith(
personId: draft.personId,
title: draft.title,
cadence: draft.cadence,
nextAt: draft.nextAt,
),
);
}
}
class _ReminderEditorDialog extends StatefulWidget {
const _ReminderEditorDialog({
required this.title,
required this.people,
this.initial,
});
final String title;
final List<PersonProfile> people;
final _ReminderDraft? initial;
@override
State<_ReminderEditorDialog> createState() => _ReminderEditorDialogState();
}
class _ReminderEditorDialogState extends State<_ReminderEditorDialog> {
late final TextEditingController _titleController;
late ReminderCadence _cadence;
late DateTime _nextAt;
String? _personId;
@override
void initState() {
super.initState();
_titleController = TextEditingController(text: widget.initial?.title ?? '');
_cadence = widget.initial?.cadence ?? ReminderCadence.weekly;
_nextAt =
widget.initial?.nextAt ?? DateTime.now().add(const Duration(days: 2));
_personId = widget.initial?.personId;
}
@override
void dispose() {
_titleController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.title),
content: SingleChildScrollView(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
TextField(
controller: _titleController,
decoration: const InputDecoration(labelText: 'Title'),
),
DropdownButtonFormField<ReminderCadence>(
initialValue: _cadence,
items: ReminderCadence.values
.map(
(ReminderCadence cadence) =>
DropdownMenuItem<ReminderCadence>(
value: cadence,
child: Text(_labelForCadence(cadence)),
),
)
.toList(growable: false),
onChanged: (ReminderCadence? value) {
if (value == null) {
return;
}
setState(() {
_cadence = value;
});
},
decoration: const InputDecoration(labelText: 'Cadence'),
),
DropdownButtonFormField<String?>(
initialValue: _personId,
items: <DropdownMenuItem<String?>>[
const DropdownMenuItem<String?>(
value: null,
child: Text('Unassigned'),
),
...widget.people.map(
(PersonProfile person) => DropdownMenuItem<String?>(
value: person.id,
child: Text(person.name),
),
),
],
onChanged: (String? value) {
setState(() {
_personId = value;
});
},
decoration: const InputDecoration(labelText: 'Person'),
),
const SizedBox(height: 12),
LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool compact = constraints.maxWidth < 360;
if (compact) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
_formatDateTime(_nextAt),
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _pickDateTime,
icon: const Icon(Icons.schedule_rounded),
label: const Text('Pick Time'),
),
],
);
}
return Row(
children: <Widget>[
Expanded(
child: Text(
_formatDateTime(_nextAt),
style: Theme.of(context).textTheme.bodyLarge,
),
),
OutlinedButton.icon(
onPressed: _pickDateTime,
icon: const Icon(Icons.schedule_rounded),
label: const Text('Pick Time'),
),
],
);
},
),
],
),
),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final String title = _titleController.text.trim();
if (title.isEmpty) {
return;
}
Navigator.of(context).pop(
_ReminderDraft(
personId: _personId,
title: title,
cadence: _cadence,
nextAt: _nextAt,
),
);
},
child: const Text('Save'),
),
],
);
}
Future<void> _pickDateTime() async {
final DateTime now = DateTime.now();
final DateTime? date = await showDatePicker(
context: context,
initialDate: _nextAt,
firstDate: now.subtract(const Duration(days: 1)),
lastDate: now.add(const Duration(days: 3650)),
);
if (date == null || !mounted) {
return;
}
final TimeOfDay? time = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(_nextAt),
);
if (time == null || !mounted) {
return;
}
setState(() {
_nextAt = DateTime(
date.year,
date.month,
date.day,
time.hour,
time.minute,
);
});
}
}
class _ReminderDraft {
const _ReminderDraft({
required this.personId,
required this.title,
required this.cadence,
required this.nextAt,
});
final String? personId;
final String title;
final ReminderCadence cadence;
final DateTime nextAt;
}
String _labelForCadence(ReminderCadence cadence) {
return switch (cadence) {
ReminderCadence.daily => 'Daily',
ReminderCadence.weekly => 'Weekly',
ReminderCadence.monthly => 'Monthly',
};
}
String _formatDateTime(DateTime value) {
final String month = value.month.toString().padLeft(2, '0');
final String day = value.day.toString().padLeft(2, '0');
final String hour = value.hour.toString().padLeft(2, '0');
final String minute = value.minute.toString().padLeft(2, '0');
return '$month/$day/${value.year} $hour:$minute';
}
// Legacy compatibility export for the reminders presentation entry.
export 'package:relationship_saver/features/reminders/presentation/reminders_view.dart';
@@ -0,0 +1,4 @@
# Reminder Scheduling
Platform notification adapters and scheduling listeners live here. This is the
boundary between reminder domain logic and device-specific behavior.
@@ -1,18 +1,2 @@
import 'dart:async';
/// Broadcasts reminder notification tap intents to the UI layer.
class ReminderNotificationIntentBus {
final StreamController<String> _controller =
StreamController<String>.broadcast();
Stream<String> get intents => _controller.stream;
void emitTap(String reminderId) {
if (reminderId.trim().isEmpty) {
return;
}
_controller.add(reminderId.trim());
}
Future<void> close() => _controller.close();
}
// Legacy compatibility export for reminder notification intent handling.
export 'package:relationship_saver/features/reminders/application/reminder_notification_intent_bus.dart';
@@ -1,67 +1,2 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/features/reminders/reminders_view.dart';
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_provider.dart';
/// Listens for reminder notification taps and opens reminder management UI.
class ReminderNotificationListener extends ConsumerStatefulWidget {
const ReminderNotificationListener({required this.child, super.key});
final Widget child;
@override
ConsumerState<ReminderNotificationListener> createState() =>
_ReminderNotificationListenerState();
}
class _ReminderNotificationListenerState
extends ConsumerState<ReminderNotificationListener> {
StreamSubscription<String>? _subscription;
@override
void initState() {
super.initState();
_subscription = ref
.read(reminderNotificationIntentBusProvider)
.intents
.listen(_handleIntent);
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
Future<void> _handleIntent(String reminderId) async {
if (!mounted) {
return;
}
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (BuildContext context) =>
_ReminderNotificationScreen(reminderId: reminderId),
),
);
}
}
class _ReminderNotificationScreen extends StatelessWidget {
const _ReminderNotificationScreen({required this.reminderId});
final String reminderId;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Reminder $reminderId')),
body: const RemindersView(),
);
}
}
// Legacy compatibility export for the reminder notification listener.
export 'package:relationship_saver/features/reminders/application/reminder_notification_listener.dart';
@@ -1,27 +1,2 @@
import 'package:relationship_saver/features/local/local_models.dart';
/// Schedules and reconciles reminder deliveries for current local state.
abstract class ReminderScheduler {
/// Reconciles platform schedules to match current enabled reminders.
Future<void> reconcile(List<ReminderRule> reminders);
/// Clears all platform-scheduled reminder jobs.
Future<void> clearAll();
/// Requests notification permissions where the platform requires them.
Future<bool> requestPermissions();
}
/// Default scheduler used until platform notifications are integrated.
class NoopReminderScheduler implements ReminderScheduler {
const NoopReminderScheduler();
@override
Future<void> clearAll() async {}
@override
Future<void> reconcile(List<ReminderRule> reminders) async {}
@override
Future<bool> requestPermissions() async => true;
}
// Legacy compatibility export for the reminders scheduling service.
export 'package:relationship_saver/features/reminders/application/reminder_scheduler.dart';
@@ -1,240 +1,2 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/reminders/scheduling/reminder_notification_intent_bus.dart';
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler.dart';
import 'package:timezone/timezone.dart' as tz;
const String _channelId = 'relationship_saver_reminders';
const String _channelName = 'Relationship Reminders';
const String _channelDescription =
'Reminders for relationship plans and follow-ups';
/// Schedules reminders with local notifications where platform support exists.
class LocalNotificationsReminderScheduler implements ReminderScheduler {
LocalNotificationsReminderScheduler({
ReminderNotificationsDriver? driver,
DateTime Function()? now,
ReminderNotificationIntentBus? intentBus,
}) : _driver =
driver ??
FlutterReminderNotificationsDriver(onTapPayload: intentBus?.emitTap),
_now = now ?? DateTime.now;
final ReminderNotificationsDriver _driver;
final DateTime Function() _now;
@override
Future<void> clearAll() async {
if (!_supportsSchedulingPlatform()) {
return;
}
await _driver.ensureInitialized();
await _driver.cancelAll();
}
@override
Future<void> reconcile(List<ReminderRule> reminders) async {
if (!_supportsSchedulingPlatform()) {
return;
}
await _driver.ensureInitialized();
await _driver.cancelAll();
final DateTime now = _now();
final List<ReminderRule> enabled = reminders
.where((ReminderRule reminder) => reminder.enabled)
.toList(growable: false);
for (final ReminderRule reminder in enabled) {
final DateTimeComponents? repeating = _repeatComponents(reminder.cadence);
if (repeating == null && reminder.nextAt.isBefore(now)) {
continue;
}
await _driver.schedule(
id: _notificationId(reminder.id),
title: reminder.title,
body: _buildBody(reminder),
scheduledAt: reminder.nextAt,
repeatComponents: repeating,
payload: reminder.id,
);
}
}
@override
Future<bool> requestPermissions() async {
if (!_supportsSchedulingPlatform()) {
return false;
}
await _driver.ensureInitialized();
return _driver.requestPermissions();
}
DateTimeComponents? _repeatComponents(ReminderCadence cadence) {
return switch (cadence) {
ReminderCadence.daily => DateTimeComponents.time,
ReminderCadence.weekly => DateTimeComponents.dayOfWeekAndTime,
ReminderCadence.monthly => DateTimeComponents.dayOfMonthAndTime,
};
}
String _buildBody(ReminderRule reminder) {
final String frequency = switch (reminder.cadence) {
ReminderCadence.daily => 'daily',
ReminderCadence.weekly => 'weekly',
ReminderCadence.monthly => 'monthly',
};
return 'Your $frequency reminder is due now.';
}
bool _supportsSchedulingPlatform() {
if (kIsWeb) {
return false;
}
return defaultTargetPlatform != TargetPlatform.linux;
}
int _notificationId(String raw) {
int hash = 0;
for (final int unit in raw.codeUnits) {
hash = ((hash * 31) + unit) & 0x7fffffff;
}
return hash;
}
}
/// Driver wrapper around the notification plugin for easier testing.
abstract class ReminderNotificationsDriver {
Future<void> ensureInitialized();
Future<void> cancelAll();
Future<void> schedule({
required int id,
required String title,
required String body,
required DateTime scheduledAt,
required DateTimeComponents? repeatComponents,
required String payload,
});
Future<bool> requestPermissions();
}
class FlutterReminderNotificationsDriver
implements ReminderNotificationsDriver {
FlutterReminderNotificationsDriver({
FlutterLocalNotificationsPlugin? plugin,
void Function(String payload)? onTapPayload,
}) : _plugin = plugin ?? FlutterLocalNotificationsPlugin(),
_onTapPayload = onTapPayload;
final FlutterLocalNotificationsPlugin _plugin;
final void Function(String payload)? _onTapPayload;
Future<void>? _initializeFuture;
@override
Future<void> ensureInitialized() {
return _initializeFuture ??= _initialize();
}
@override
Future<void> cancelAll() async {
await _plugin.cancelAll();
}
@override
Future<void> schedule({
required int id,
required String title,
required String body,
required DateTime scheduledAt,
required DateTimeComponents? repeatComponents,
required String payload,
}) async {
await _plugin.zonedSchedule(
id: id,
title: title,
body: body,
scheduledDate: tz.TZDateTime.from(scheduledAt.toUtc(), tz.UTC),
notificationDetails: _notificationDetails,
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
matchDateTimeComponents: repeatComponents,
payload: payload,
);
}
@override
Future<bool> requestPermissions() async {
if (defaultTargetPlatform == TargetPlatform.android) {
return await _plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.requestNotificationsPermission() ??
false;
}
if (defaultTargetPlatform == TargetPlatform.iOS) {
return await _plugin
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin
>()
?.requestPermissions(alert: true, badge: true, sound: true) ??
false;
}
if (defaultTargetPlatform == TargetPlatform.macOS) {
return await _plugin
.resolvePlatformSpecificImplementation<
MacOSFlutterLocalNotificationsPlugin
>()
?.requestPermissions(alert: true, badge: true, sound: true) ??
false;
}
return true;
}
Future<void> _initialize() async {
await _plugin.initialize(
settings: const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
iOS: DarwinInitializationSettings(),
macOS: DarwinInitializationSettings(),
linux: LinuxInitializationSettings(defaultActionName: 'Open reminders'),
windows: WindowsInitializationSettings(
appName: 'Relationship Saver',
appUserModelId: 'com.relationshipsaver.app',
guid: 'ee23c0af-5898-4d96-b599-cf2d7d0d37d7',
),
),
onDidReceiveNotificationResponse: (NotificationResponse response) {
final String? payload = response.payload;
if (payload == null || payload.isEmpty) {
return;
}
_onTapPayload?.call(payload);
},
);
}
NotificationDetails get _notificationDetails {
return const NotificationDetails(
android: AndroidNotificationDetails(
_channelId,
_channelName,
channelDescription: _channelDescription,
importance: Importance.high,
priority: Priority.high,
),
iOS: DarwinNotificationDetails(),
macOS: DarwinNotificationDetails(),
linux: LinuxNotificationDetails(),
windows: WindowsNotificationDetails(),
);
}
}
// Legacy compatibility export for the local notifications reminder backend.
export 'package:relationship_saver/features/reminders/application/reminder_scheduler_local_notifications.dart';
@@ -1,30 +1,2 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_config.dart';
import 'package:relationship_saver/features/reminders/scheduling/reminder_notification_intent_bus.dart';
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler.dart';
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_local_notifications.dart';
/// Broadcasts reminder notification tap intents to interested UI listeners.
final Provider<ReminderNotificationIntentBus>
reminderNotificationIntentBusProvider = Provider<ReminderNotificationIntentBus>(
(Ref ref) {
final ReminderNotificationIntentBus bus = ReminderNotificationIntentBus();
ref.onDispose(() {
unawaited(bus.close());
});
return bus;
},
);
/// Provides reminder delivery scheduler implementation.
final Provider<ReminderScheduler> reminderSchedulerProvider =
Provider<ReminderScheduler>((Ref ref) {
if (AppConfig.enableLocalNotifications) {
return LocalNotificationsReminderScheduler(
intentBus: ref.watch(reminderNotificationIntentBusProvider),
);
}
return const NoopReminderScheduler();
});
// Legacy compatibility export for the reminder scheduler provider.
export 'package:relationship_saver/features/reminders/application/reminder_scheduler_provider.dart';
+6
View File
@@ -0,0 +1,6 @@
# Settings Slice
This slice owns settings and trust/privacy messaging.
The screen is intentionally simple. This is the right place for local-first
copy, future AI-sharing controls, and developer/founder-mode toggles.
@@ -0,0 +1,4 @@
# Settings Presentation
Settings UI belongs here. This is the place for privacy wording, local-first
trust messaging, and user-facing controls around app behavior.
@@ -0,0 +1,691 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_config.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/core/llm/llm_config.dart';
import 'package:relationship_saver/features/ai_digest/application/llm_digest_background_scheduler.dart';
import 'package:relationship_saver/features/ai_digest/application/llm_digest_orchestrator.dart';
import 'package:relationship_saver/features/ai_digest/data/llm_digest_config.dart';
import 'package:relationship_saver/features/ai_digest/presentation/ai_digest_review_view.dart';
import 'package:relationship_saver/features/auth/session_controller.dart';
import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_provider.dart';
import 'package:relationship_saver/features/share_intake/share_capture_flow.dart';
import 'package:relationship_saver/features/share_intake/share_capture_models.dart';
import 'package:relationship_saver/features/share_intake/share_inbox_view.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
class SettingsView extends ConsumerWidget {
const SettingsView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final AuthSession? session = ref
.watch(sessionControllerProvider)
.asData
?.value;
final LlmDigestConfigState digestConfig = ref.watch(
llmDigestConfigProvider,
);
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool compact = constraints.maxWidth < 760;
return Padding(
padding: EdgeInsets.fromLTRB(
compact ? 16 : 28,
compact ? 16 : 24,
compact ? 16 : 28,
compact ? 20 : 28,
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Settings',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'Environment and integration configuration for this build.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 16),
FrostedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_SettingRow(
compact: compact,
label: 'Signed in as',
value:
session?.user.email ??
session?.user.id ??
'Not signed in',
),
const SizedBox(height: 12),
_SettingRow(
compact: compact,
label: 'Gateway mode',
value: AppConfig.useFakeBackend
? 'Fake (offline-safe)'
: 'REST',
),
const SizedBox(height: 12),
_SettingRow(
compact: compact,
label: 'Base URL',
value: AppConfig.backendBaseUrl,
),
const SizedBox(height: 12),
_SettingRow(
compact: compact,
label: 'Realtime',
value: 'Planned later (SSE/WebSocket)',
),
const SizedBox(height: 12),
_SettingRow(
compact: compact,
label: 'Local notifications',
value: AppConfig.enableLocalNotifications
? 'Enabled'
: 'Disabled',
),
const SizedBox(height: 12),
_SettingRow(
compact: compact,
label: 'LLM AI',
value: ref.watch(llmConfigProvider).isConfigured
? '${_providerLabel(ref.watch(llmConfigProvider).provider)} (configured)'
: 'Not configured',
),
const SizedBox(height: 12),
_SettingRow(
compact: compact,
label: 'AI digest',
value: digestConfig.enabled
? 'Weekly, ${_weekdayLabel(digestConfig.preferredWeekday)} ${digestConfig.preferredHour.toString().padLeft(2, '0')}:00'
: 'Off',
),
const SizedBox(height: 12),
_SettingRow(
compact: compact,
label: 'AI network',
value:
'${digestConfig.requireWifi ? 'Wi-Fi' : 'Any network'} + ${digestConfig.requireCharging ? 'charging' : 'battery allowed'}',
),
const SizedBox(height: 14),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
OutlinedButton.icon(
onPressed: () => _configureLlm(context, ref),
icon: const Icon(Icons.smart_toy_rounded),
label: Text(
ref.watch(llmConfigProvider).isConfigured
? 'Configure AI'
: 'Setup AI',
),
),
OutlinedButton.icon(
onPressed: () => _toggleDigest(context, ref),
icon: Icon(
digestConfig.enabled
? Icons.event_busy_rounded
: Icons.event_repeat_rounded,
),
label: Text(
digestConfig.enabled
? 'Disable Weekly AI Digest'
: 'Enable Weekly AI Digest',
),
),
OutlinedButton.icon(
onPressed: ref.watch(llmConfigProvider).isConfigured
? () => _runPrivateDigest(context, ref)
: null,
icon: const Icon(Icons.auto_awesome_rounded),
label: const Text('Run Private Digest Now'),
),
OutlinedButton.icon(
onPressed: () => Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (BuildContext context) =>
const AiDigestReviewView(),
),
),
icon: const Icon(Icons.rate_review_rounded),
label: const Text('Open AI Review'),
),
OutlinedButton.icon(
onPressed: session == null
? null
: () => _signOut(context, ref),
icon: const Icon(Icons.logout_rounded),
label: const Text('Sign Out'),
),
OutlinedButton.icon(
onPressed: session == null
? null
: () {
ref
.read(
sessionControllerProvider.notifier,
)
.refreshProfile();
},
icon: const Icon(Icons.refresh_rounded),
label: const Text('Refresh Profile'),
),
OutlinedButton.icon(
onPressed: AppConfig.enableLocalNotifications
? () => _requestNotificationPermissions(
context,
ref,
)
: null,
icon: const Icon(
Icons.notifications_active_rounded,
),
label: const Text('Request Notification Access'),
),
OutlinedButton.icon(
onPressed: () =>
_simulateShareCapture(context, ref),
icon: const Icon(Icons.message_rounded),
label: const Text('Simulate Share Capture'),
),
OutlinedButton.icon(
onPressed: () => Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (BuildContext context) =>
const ShareInboxView(),
),
),
icon: const Icon(Icons.inbox_rounded),
label: const Text('Open Share Inbox'),
),
],
),
],
),
),
const SizedBox(height: 16),
FrostedCard(
child: Text(
'Local-first privacy\n• Relationship details stay on this device in the current build.\n• Scheduled AI uses pseudonymous person tokens and a review inbox.\n• Names, aliases, sender names, URLs, and raw shared text are not sent in the digest payload.\n• iOS background digest runs are best-effort; manual runs are available here.\n\nRun options\n• Fake mode: flutter run --dart-define=USE_FAKE_BACKEND=true\n• REST mode: flutter run --dart-define=USE_FAKE_BACKEND=false --dart-define=BACKEND_BASE_URL=https://your-api',
style: Theme.of(context).textTheme.bodyLarge,
),
),
],
),
),
);
},
);
}
String _providerLabel(LlmProvider provider) {
switch (provider) {
case LlmProvider.openai:
return 'OpenAI';
case LlmProvider.anthropic:
return 'Anthropic';
case LlmProvider.google:
return 'Google AI';
}
}
String _weekdayLabel(int weekday) {
return switch (weekday) {
DateTime.monday => 'Mon',
DateTime.tuesday => 'Tue',
DateTime.wednesday => 'Wed',
DateTime.thursday => 'Thu',
DateTime.friday => 'Fri',
DateTime.saturday => 'Sat',
DateTime.sunday => 'Sun',
_ => 'Sun',
};
}
Future<void> _configureLlm(BuildContext context, WidgetRef ref) async {
final LlmConfigState current = ref.read(llmConfigProvider);
final _LlmConfigDraft? draft = await showDialog<_LlmConfigDraft>(
context: context,
builder: (BuildContext context) => _LlmConfigDialog(
currentProvider: current.provider,
hasApiKey: current.apiKeyConfigured,
),
);
if (draft == null || !context.mounted) {
return;
}
await ref.read(llmConfigProvider.notifier).setProvider(draft.provider);
if (draft.clearApiKey) {
await ref.read(llmConfigProvider.notifier).clearApiKey();
} else if (draft.apiKey.isNotEmpty) {
await ref.read(llmConfigProvider.notifier).setApiKey(draft.apiKey);
}
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
draft.apiKey.isNotEmpty
? 'AI configuration saved.'
: draft.clearApiKey
? 'AI API key cleared.'
: 'AI configuration saved.',
),
),
);
}
}
Future<void> _toggleDigest(BuildContext context, WidgetRef ref) async {
final LlmDigestConfigState current = ref.read(llmDigestConfigProvider);
final bool nextEnabled = !current.enabled;
await ref.read(llmDigestConfigProvider.notifier).setEnabled(nextEnabled);
await ref
.read(llmDigestBackgroundSchedulerProvider)
.configure(ref.read(llmDigestConfigProvider));
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
nextEnabled
? 'Weekly private AI digest enabled.'
: 'Weekly private AI digest disabled.',
),
),
);
}
Future<void> _runPrivateDigest(BuildContext context, WidgetRef ref) async {
final ScaffoldMessengerState messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(
const SnackBar(content: Text('Running private AI digest...')),
);
final LlmDigestRunResult result = await ref
.read(llmDigestOrchestratorProvider)
.runManualDigest();
if (!context.mounted) {
return;
}
messenger.showSnackBar(
SnackBar(
content: Text(
result.completed
? 'Private digest created ${result.createdDraftCount} suggestions.'
: result.reason ?? 'Private digest did not complete.',
),
),
);
}
Future<void> _signOut(BuildContext context, WidgetRef ref) async {
await ref.read(sessionControllerProvider.notifier).signOut();
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Signed out.')));
}
}
Future<void> _requestNotificationPermissions(
BuildContext context,
WidgetRef ref,
) async {
final bool granted = await ref
.read(reminderSchedulerProvider)
.requestPermissions();
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
granted
? 'Notification access granted.'
: 'Notification access not granted.',
),
),
);
}
Future<void> _simulateShareCapture(
BuildContext context,
WidgetRef ref,
) async {
final String platformName = Theme.of(context).platform.name;
final _ShareSimulationDraft? draft =
await showDialog<_ShareSimulationDraft>(
context: context,
builder: (BuildContext context) => const _ShareSimulationDialog(),
);
if (draft == null) {
return;
}
if (!context.mounted) {
return;
}
final SharedPayload? payload = buildSharedPayloadFromRawText(
rawText: draft.rawText,
platform: platformName,
sourceAppHint: draft.sourceAppHint,
);
if (payload == null) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Shared payload is empty.')));
return;
}
final SharedMessageIngestResult? result = await startShareCaptureReview(
context,
ref: ref,
payload: payload,
);
if (!context.mounted || result == null) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(shareCaptureResultMessage(result)),
action: SnackBarAction(
label: result.isQueuedForResolution ? 'Open Inbox' : 'Open Profile',
onPressed: () =>
openShareCaptureDestination(context, ref: ref, result: result),
),
),
);
}
}
class _SettingRow extends StatelessWidget {
const _SettingRow({
required this.label,
required this.value,
required this.compact,
});
final String label;
final String value;
final bool compact;
@override
Widget build(BuildContext context) {
if (compact) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
label,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 4),
Text(value, style: Theme.of(context).textTheme.bodyLarge),
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SizedBox(
width: 140,
child: Text(
label,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(color: AppTheme.textSecondary),
),
),
Expanded(
child: Text(value, style: Theme.of(context).textTheme.bodyLarge),
),
],
);
}
}
class _LlmConfigDraft {
const _LlmConfigDraft({
required this.provider,
required this.apiKey,
this.clearApiKey = false,
});
final LlmProvider provider;
final String apiKey;
final bool clearApiKey;
}
class _LlmConfigDialog extends StatefulWidget {
const _LlmConfigDialog({
required this.currentProvider,
required this.hasApiKey,
});
final LlmProvider currentProvider;
final bool hasApiKey;
@override
State<_LlmConfigDialog> createState() => _LlmConfigDialogState();
}
class _LlmConfigDialogState extends State<_LlmConfigDialog> {
late LlmProvider _selectedProvider;
late final TextEditingController _apiKeyController;
@override
void initState() {
super.initState();
_selectedProvider = widget.currentProvider;
_apiKeyController = TextEditingController();
}
@override
void dispose() {
_apiKeyController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Configure AI Provider'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text('Provider'),
const SizedBox(height: 8),
DropdownButtonFormField<LlmProvider>(
initialValue: _selectedProvider,
decoration: const InputDecoration(border: OutlineInputBorder()),
items: const <DropdownMenuItem<LlmProvider>>[
DropdownMenuItem<LlmProvider>(
value: LlmProvider.openai,
child: Text('OpenAI'),
),
DropdownMenuItem<LlmProvider>(
value: LlmProvider.anthropic,
child: Text('Anthropic (Claude)'),
),
DropdownMenuItem<LlmProvider>(
value: LlmProvider.google,
child: Text('Google AI'),
),
],
onChanged: (LlmProvider? value) {
if (value != null) {
setState(() {
_selectedProvider = value;
});
}
},
),
const SizedBox(height: 16),
const Text('API Key'),
const SizedBox(height: 8),
TextField(
controller: _apiKeyController,
obscureText: true,
decoration: InputDecoration(
hintText: widget.hasApiKey
? 'Leave empty to keep current'
: 'Enter your API key',
border: const OutlineInputBorder(),
),
),
],
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
if (widget.hasApiKey)
TextButton(
onPressed: () {
Navigator.of(context).pop(
_LlmConfigDraft(
provider: _selectedProvider,
apiKey: '',
clearApiKey: true,
),
);
},
child: const Text('Clear Key'),
),
FilledButton(
onPressed: () {
Navigator.of(context).pop(
_LlmConfigDraft(
provider: _selectedProvider,
apiKey: _apiKeyController.text.trim(),
),
);
},
child: const Text('Save'),
),
],
);
}
}
class _ShareSimulationDraft {
const _ShareSimulationDraft({
required this.rawText,
required this.sourceAppHint,
});
final String rawText;
final String sourceAppHint;
}
class _ShareSimulationDialog extends StatefulWidget {
const _ShareSimulationDialog();
@override
State<_ShareSimulationDialog> createState() => _ShareSimulationDialogState();
}
class _ShareSimulationDialogState extends State<_ShareSimulationDialog> {
late final TextEditingController _controller;
String _selectedSourceApp = 'whatsapp';
@override
void initState() {
super.initState();
_controller = TextEditingController(
text: '[2/18/26, 20:11] Ava Hart: Can we do coffee this Saturday?',
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Simulate Share Capture'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
DropdownButtonFormField<String>(
initialValue: _selectedSourceApp,
decoration: const InputDecoration(labelText: 'Source app'),
items: const <DropdownMenuItem<String>>[
DropdownMenuItem<String>(
value: 'whatsapp',
child: Text('WhatsApp'),
),
DropdownMenuItem<String>(value: 'signal', child: Text('Signal')),
DropdownMenuItem<String>(value: 'notes', child: Text('Notes')),
DropdownMenuItem<String>(
value: 'share_sheet',
child: Text('Generic text/link'),
),
],
onChanged: (String? value) {
if (value == null) {
return;
}
setState(() {
_selectedSourceApp = value;
});
},
),
const SizedBox(height: 12),
TextField(
controller: _controller,
minLines: 3,
maxLines: 6,
decoration: const InputDecoration(
hintText: 'Paste shared text or URL payload',
),
),
],
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final String rawText = _controller.text.trim();
if (rawText.isEmpty) {
return;
}
Navigator.of(context).pop(
_ShareSimulationDraft(
rawText: rawText,
sourceAppHint: _selectedSourceApp,
),
);
},
child: const Text('Import'),
),
],
);
}
}
+2 -386
View File
@@ -1,386 +1,2 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_config.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/auth/session_controller.dart';
import 'package:relationship_saver/features/local/local_repository.dart';
import 'package:relationship_saver/features/people/people_view.dart';
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_provider.dart';
import 'package:relationship_saver/features/share_intake/share_inbox_view.dart';
import 'package:relationship_saver/features/share_intake/whatsapp_share_parser.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
class SettingsView extends ConsumerWidget {
const SettingsView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final AuthSession? session = ref
.watch(sessionControllerProvider)
.asData
?.value;
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final bool compact = constraints.maxWidth < 760;
return Padding(
padding: EdgeInsets.fromLTRB(
compact ? 16 : 28,
compact ? 16 : 24,
compact ? 16 : 28,
compact ? 20 : 28,
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Settings',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
'Environment and integration configuration for this build.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 16),
FrostedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_SettingRow(
compact: compact,
label: 'Signed in as',
value:
session?.user.email ??
session?.user.id ??
'Not signed in',
),
const SizedBox(height: 12),
_SettingRow(
compact: compact,
label: 'Gateway mode',
value: AppConfig.useFakeBackend
? 'Fake (offline-safe)'
: 'REST',
),
const SizedBox(height: 12),
_SettingRow(
compact: compact,
label: 'Base URL',
value: AppConfig.backendBaseUrl,
),
const SizedBox(height: 12),
_SettingRow(
compact: compact,
label: 'Realtime',
value: 'Planned later (SSE/WebSocket)',
),
const SizedBox(height: 12),
_SettingRow(
compact: compact,
label: 'Local notifications',
value: AppConfig.enableLocalNotifications
? 'Enabled'
: 'Disabled',
),
const SizedBox(height: 14),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
FilledButton.icon(
onPressed: session == null
? null
: () => _signOut(context, ref),
icon: const Icon(Icons.logout_rounded),
label: const Text('Sign Out'),
),
OutlinedButton.icon(
onPressed: session == null
? null
: () {
ref
.read(
sessionControllerProvider.notifier,
)
.refreshProfile();
},
icon: const Icon(Icons.refresh_rounded),
label: const Text('Refresh Profile'),
),
OutlinedButton.icon(
onPressed: AppConfig.enableLocalNotifications
? () => _requestNotificationPermissions(
context,
ref,
)
: null,
icon: const Icon(
Icons.notifications_active_rounded,
),
label: const Text('Request Notification Access'),
),
OutlinedButton.icon(
onPressed: () =>
_simulateWhatsAppShare(context, ref),
icon: const Icon(Icons.message_rounded),
label: const Text('Simulate WhatsApp Share'),
),
OutlinedButton.icon(
onPressed: () => Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (BuildContext context) =>
const ShareInboxView(),
),
),
icon: const Icon(Icons.inbox_rounded),
label: const Text('Open Share Inbox'),
),
],
),
],
),
),
const SizedBox(height: 16),
FrostedCard(
child: Text(
'Run options\n• Fake mode: flutter run --dart-define=USE_FAKE_BACKEND=true\n• REST mode: flutter run --dart-define=USE_FAKE_BACKEND=false --dart-define=BACKEND_BASE_URL=https://your-api',
style: Theme.of(context).textTheme.bodyLarge,
),
),
],
),
),
);
},
);
}
Future<void> _signOut(BuildContext context, WidgetRef ref) async {
await ref.read(sessionControllerProvider.notifier).signOut();
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Signed out.')));
}
}
Future<void> _requestNotificationPermissions(
BuildContext context,
WidgetRef ref,
) async {
final bool granted = await ref
.read(reminderSchedulerProvider)
.requestPermissions();
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
granted
? 'Notification access granted.'
: 'Notification access not granted.',
),
),
);
}
Future<void> _simulateWhatsAppShare(
BuildContext context,
WidgetRef ref,
) async {
final _ShareSimulationDraft? draft =
await showDialog<_ShareSimulationDraft>(
context: context,
builder: (BuildContext context) => const _ShareSimulationDialog(),
);
if (draft == null) {
return;
}
final WhatsAppSharePayload parsed = WhatsAppShareParser.parse(
draft.rawText,
);
if (parsed.messageText.trim().isEmpty) {
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Message text is empty.')));
return;
}
final SharedMessageIngestResult result = await ref
.read(localRepositoryProvider.notifier)
.ingestSharedMessage(
SharedMessageIngestInput(
sourceApp: 'whatsapp',
messageText: parsed.messageText,
sourceDisplayName: parsed.sourceDisplayName,
sourceUserId: parsed.sourceUserId,
sourceThreadId: parsed.sourceThreadId,
sharedAt: DateTime.now(),
),
);
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
result.isQueuedForResolution
? 'Imported to Share Inbox for profile resolution.'
: result.createdProfile
? 'Imported and created ${result.profileName}.'
: 'Imported to ${result.profileName}.',
),
action: SnackBarAction(
label: result.isQueuedForResolution ? 'Open Inbox' : 'Open Profile',
onPressed: () => _openShareDestination(context, ref, result),
),
),
);
}
void _openShareDestination(
BuildContext context,
WidgetRef ref,
SharedMessageIngestResult result,
) {
if (result.isQueuedForResolution) {
Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (BuildContext context) => const ShareInboxView(),
),
);
return;
}
if (result.profileId != null) {
ref.read(selectedPersonIdProvider.notifier).select(result.profileId!);
}
Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (BuildContext context) => const PeopleView(),
),
);
}
}
class _SettingRow extends StatelessWidget {
const _SettingRow({
required this.label,
required this.value,
required this.compact,
});
final String label;
final String value;
final bool compact;
@override
Widget build(BuildContext context) {
if (compact) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
label,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 4),
Text(value, style: Theme.of(context).textTheme.bodyLarge),
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SizedBox(
width: 140,
child: Text(
label,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(color: AppTheme.textSecondary),
),
),
Expanded(
child: Text(value, style: Theme.of(context).textTheme.bodyLarge),
),
],
);
}
}
class _ShareSimulationDraft {
const _ShareSimulationDraft({required this.rawText});
final String rawText;
}
class _ShareSimulationDialog extends StatefulWidget {
const _ShareSimulationDialog();
@override
State<_ShareSimulationDialog> createState() => _ShareSimulationDialogState();
}
class _ShareSimulationDialogState extends State<_ShareSimulationDialog> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(
text: '[2/18/26, 20:11] Ava Hart: Can we do coffee this Saturday?',
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Simulate WhatsApp Share'),
content: TextField(
controller: _controller,
minLines: 3,
maxLines: 6,
decoration: const InputDecoration(
hintText: 'Paste shared WhatsApp text payload',
),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
final String rawText = _controller.text.trim();
if (rawText.isEmpty) {
return;
}
Navigator.of(context).pop(_ShareSimulationDraft(rawText: rawText));
},
child: const Text('Import'),
),
],
);
}
}
// Legacy compatibility export for the settings presentation entry.
export 'package:relationship_saver/features/settings/presentation/settings_view.dart';
+12
View File
@@ -0,0 +1,12 @@
# Share Intake Slice
This slice owns the highest-friction capture path: content arriving from other
apps.
- `domain/share_models.dart`: normalized share payloads, drafts, inbox entries.
- `domain/*parser*.dart`: payload parsing and source-specific parsing helpers.
- `application/share_capture_*.dart`: orchestration contracts and routing flow.
- `presentation/`: listener widgets, review sheet, inbox UI.
If you want to improve share UX, ambiguity handling, or payload normalization,
start here.
@@ -0,0 +1,4 @@
# Share Intake Application
This layer coordinates share-entry routing and the review flow. If content comes
in from outside the app, this folder decides how it enters the local workflow.
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/people/presentation/people_view.dart';
import 'package:relationship_saver/features/people/presentation/providers/people_ui_state.dart';
import 'package:relationship_saver/features/share_intake/share_capture_models.dart';
import 'package:relationship_saver/features/share_intake/share_capture_review_sheet.dart';
import 'package:relationship_saver/features/share_intake/share_inbox_view.dart';
import 'package:relationship_saver/features/share_intake/share_payload_parser.dart';
SharedPayload? buildSharedPayloadFromRawText({
required String rawText,
required String platform,
String? sourceAppHint,
}) {
final String trimmed = rawText.trim();
if (trimmed.isEmpty) {
return null;
}
final String sourceApp = SharePayloadParser.detectSourceApp(
rawText: trimmed,
sourceAppHint: sourceAppHint,
);
final SharedPayload payload = SharePayloadParser.parse(
rawText: trimmed,
sourceApp: sourceApp,
platform: platform,
createdAt: DateTime.now(),
receivedAt: DateTime.now(),
);
if (payload.rawText.trim().isEmpty && (payload.url?.trim().isEmpty ?? true)) {
return null;
}
return payload;
}
Future<SharedMessageIngestResult?> startShareCaptureReview(
BuildContext context, {
required WidgetRef ref,
required SharedPayload payload,
}) {
return showShareCaptureReviewSheet(
context,
ref: ref,
payload: payload,
initialPersonId: ref.read(selectedPersonIdProvider),
);
}
void openShareCaptureDestination(
BuildContext context, {
required WidgetRef ref,
required SharedMessageIngestResult result,
}) {
if (result.isQueuedForResolution) {
Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (BuildContext context) => const ShareInboxView(),
),
);
return;
}
if (result.profileId != null) {
ref.read(selectedPersonIdProvider.notifier).select(result.profileId!);
}
Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (BuildContext context) => const PeopleView(),
),
);
}
String shareCaptureResultMessage(SharedMessageIngestResult result) {
if (result.isQueuedForResolution) {
return 'Saved shared capture to inbox.';
}
if (result.createdProfile) {
return 'Saved shared capture and created ${result.profileName}.';
}
return 'Saved shared capture to ${result.profileName}.';
}
@@ -0,0 +1,60 @@
class SharedMessageIngestInput {
const SharedMessageIngestInput({
required this.sourceApp,
required this.messageText,
this.sourceDisplayName,
this.sourceUserId,
this.sourceThreadId,
this.sharedAt,
});
final String sourceApp;
final String messageText;
final String? sourceDisplayName;
final String? sourceUserId;
final String? sourceThreadId;
final DateTime? sharedAt;
}
enum SharedMessageIngestStatus { imported, queuedForResolution }
class SharedMessageIngestResult {
const SharedMessageIngestResult({
required this.status,
required this.createdProfile,
required this.createdSourceLink,
this.profileId,
this.profileName,
this.momentId,
this.inboxEntryId,
});
const SharedMessageIngestResult.imported({
required this.profileId,
required this.profileName,
required this.createdProfile,
required this.createdSourceLink,
required this.momentId,
}) : status = SharedMessageIngestStatus.imported,
inboxEntryId = null;
const SharedMessageIngestResult.queuedForResolution({
required this.inboxEntryId,
}) : status = SharedMessageIngestStatus.queuedForResolution,
createdProfile = false,
createdSourceLink = false,
profileId = null,
profileName = null,
momentId = null;
final SharedMessageIngestStatus status;
final String? profileId;
final String? profileName;
final bool createdProfile;
final bool createdSourceLink;
final String? momentId;
final String? inboxEntryId;
bool get isQueuedForResolution =>
status == SharedMessageIngestStatus.queuedForResolution;
}
@@ -0,0 +1,4 @@
# Share Intake Domain
Normalized payloads, inbox entries, fact drafts, and parsing helpers live here.
Start here when changing how raw shared content becomes structured app data.
@@ -0,0 +1,232 @@
import 'package:relationship_saver/features/local/local_models.dart';
class ShareCaptureDraftSuggester {
const ShareCaptureDraftSuggester._();
static final RegExp _birthdayPattern = RegExp(
r'\bbirthday\b',
caseSensitive: false,
);
static final RegExp _anniversaryPattern = RegExp(
r'\banniversary\b',
caseSensitive: false,
);
static final RegExp _giftPattern = RegExp(
r'\b(gift|present|flowers|scarf|book for her|book for him)\b',
caseSensitive: false,
);
static final RegExp _placePattern = RegExp(
r'\b(restaurant|cafe|coffee shop|bar|bistro|museum|park|hotel|beach|address|reservation)\b',
caseSensitive: false,
);
static final RegExp _activityPattern = RegExp(
r'\b(concert|movie|hike|walk|trip|class|event|show|plan|weekend|tickets?)\b',
caseSensitive: false,
);
static final RegExp _negativePreferencePattern = RegExp(
r"\b(hates?|dislikes?|allergic to|doesn't like|does not like|dont like)\b",
caseSensitive: false,
);
static final RegExp _positivePreferencePattern = RegExp(
r'\b(loves?|likes?|favorite|prefers?|enjoys?)\b',
caseSensitive: false,
);
static final RegExp _isoDatePattern = RegExp(
r'\b(\d{4})-(\d{1,2})-(\d{1,2})\b',
);
static final RegExp _dotDatePattern = RegExp(
r'\b(\d{1,2})\.(\d{1,2})\.(\d{2,4})\b',
);
static final RegExp _monthNameDatePattern = RegExp(
r'\b(january|february|march|april|may|june|july|august|september|october|november|december)\s+(\d{1,2})(?:,\s*(\d{4}))?\b',
caseSensitive: false,
);
static CapturedFactDraft suggestForPayload(SharedPayload payload) {
final String text = payload.rawText.trim().isNotEmpty
? payload.rawText.trim()
: (payload.url ?? '').trim();
final String normalized = text.toLowerCase();
final Uri? uri = payload.url == null ? null : Uri.tryParse(payload.url!);
final String host = uri?.host.toLowerCase() ?? '';
final _SuggestedImportantDate? importantDate = _suggestImportantDate(text);
if (importantDate != null) {
return CapturedFactDraft(
type: CapturedFactType.importantDate,
text: importantDate.label,
label: importantDate.label,
dateValue: importantDate.date,
isSensitive: false,
needsReview: true,
);
}
if (_giftPattern.hasMatch(normalized)) {
return CapturedFactDraft(
type: CapturedFactType.giftIdea,
text: text,
label: 'Gift idea',
isSensitive: false,
needsReview: true,
);
}
if (_looksLikePlace(normalized, host)) {
return CapturedFactDraft(
type: CapturedFactType.placeIdea,
text: text,
label: 'Place idea',
isSensitive: false,
needsReview: true,
);
}
if (_looksLikeActivity(normalized, host)) {
return CapturedFactDraft(
type: CapturedFactType.activityIdea,
text: text,
label: 'Activity idea',
isSensitive: false,
needsReview: true,
);
}
if (_negativePreferencePattern.hasMatch(normalized)) {
return CapturedFactDraft(
type: CapturedFactType.dislike,
text: text,
label: 'Preference',
isSensitive: normalized.contains('allergic'),
needsReview: true,
);
}
if (_positivePreferencePattern.hasMatch(normalized)) {
return CapturedFactDraft(
type: CapturedFactType.like,
text: text,
label: 'Preference',
isSensitive: false,
needsReview: true,
);
}
return CapturedFactDraft(
type: CapturedFactType.note,
text: text,
label: payload.url == null ? null : 'Shared link',
isSensitive: false,
needsReview: payload.url != null,
);
}
static bool _looksLikePlace(String normalized, String host) {
if (_placePattern.hasMatch(normalized)) {
return true;
}
return host.contains('maps.apple.com') ||
host.contains('google.com') && normalized.contains('maps') ||
host.contains('tripadvisor.') ||
host.contains('opentable.') ||
host.contains('thefork.');
}
static bool _looksLikeActivity(String normalized, String host) {
if (_activityPattern.hasMatch(normalized)) {
return true;
}
return host.contains('eventbrite.') ||
host.contains('meetup.') ||
host.contains('ticketmaster.');
}
static _SuggestedImportantDate? _suggestImportantDate(String text) {
final DateTime? date = _parseDate(text);
if (date == null) {
return null;
}
final String normalized = text.toLowerCase();
if (_birthdayPattern.hasMatch(normalized)) {
return _SuggestedImportantDate(date: date, label: 'Birthday');
}
if (_anniversaryPattern.hasMatch(normalized)) {
return _SuggestedImportantDate(date: date, label: 'Anniversary');
}
return null;
}
static DateTime? _parseDate(String text) {
final RegExpMatch? isoMatch = _isoDatePattern.firstMatch(text);
if (isoMatch != null) {
return _safeDate(
int.parse(isoMatch.group(1)!),
int.parse(isoMatch.group(2)!),
int.parse(isoMatch.group(3)!),
);
}
final RegExpMatch? dotMatch = _dotDatePattern.firstMatch(text);
if (dotMatch != null) {
final int year = int.parse(dotMatch.group(3)!);
return _safeDate(
year < 100 ? 2000 + year : year,
int.parse(dotMatch.group(2)!),
int.parse(dotMatch.group(1)!),
);
}
final RegExpMatch? monthNameMatch = _monthNameDatePattern.firstMatch(text);
if (monthNameMatch != null) {
final int? month = _monthNameToInt(monthNameMatch.group(1)!);
if (month == null) {
return null;
}
final int day = int.parse(monthNameMatch.group(2)!);
final int year = monthNameMatch.group(3) == null
? DateTime.now().year
: int.parse(monthNameMatch.group(3)!);
return _safeDate(year, month, day);
}
return null;
}
static DateTime? _safeDate(int year, int month, int day) {
try {
final DateTime value = DateTime(year, month, day);
if (value.year != year || value.month != month || value.day != day) {
return null;
}
return value;
} catch (_) {
return null;
}
}
static int? _monthNameToInt(String input) {
return switch (input.toLowerCase()) {
'january' => 1,
'february' => 2,
'march' => 3,
'april' => 4,
'may' => 5,
'june' => 6,
'july' => 7,
'august' => 8,
'september' => 9,
'october' => 10,
'november' => 11,
'december' => 12,
_ => null,
};
}
}
class _SuggestedImportantDate {
const _SuggestedImportantDate({required this.date, required this.label});
final DateTime date;
final String label;
}
@@ -0,0 +1,456 @@
// ignore_for_file: sort_constructors_first
import 'package:flutter/foundation.dart';
/// Supported raw payload shapes entering the app from the share pipeline.
enum SharedPayloadType { text, url, textWithUrl }
/// User-facing assignment outcome for a shared payload.
enum ShareAssignmentStatus {
needsPersonSelection,
assignedDirectly,
savedToInbox,
discarded,
}
/// Structured capture types that shared or manual content can resolve into.
enum CapturedFactType {
note,
like,
dislike,
importantDate,
giftIdea,
placeIdea,
activityIdea,
misc,
}
/// Tracks how a fact or date entered local storage.
enum CaptureSourceKind { manual, shareSheet, shareInbox }
/// Normalized input payload shared from another app.
@immutable
class SharedPayload {
const SharedPayload({
required this.sourceApp,
required this.payloadType,
required this.rawText,
required this.createdAt,
required this.receivedAt,
required this.platform,
this.url,
this.sourceDisplayName,
this.sourceUserId,
this.sourceThreadId,
this.parsingHints = const <String>[],
this.metadata = const <String, String>{},
this.dedupeKey,
});
final String sourceApp;
final SharedPayloadType payloadType;
final String rawText;
final String? url;
final DateTime createdAt;
final DateTime receivedAt;
final String platform;
final String? sourceDisplayName;
final String? sourceUserId;
final String? sourceThreadId;
final List<String> parsingHints;
final Map<String, String> metadata;
final String? dedupeKey;
SharedPayload copyWith({
String? sourceApp,
SharedPayloadType? payloadType,
String? rawText,
String? url,
DateTime? createdAt,
DateTime? receivedAt,
String? platform,
String? sourceDisplayName,
String? sourceUserId,
String? sourceThreadId,
List<String>? parsingHints,
Map<String, String>? metadata,
String? dedupeKey,
}) {
return SharedPayload(
sourceApp: sourceApp ?? this.sourceApp,
payloadType: payloadType ?? this.payloadType,
rawText: rawText ?? this.rawText,
url: url ?? this.url,
createdAt: createdAt ?? this.createdAt,
receivedAt: receivedAt ?? this.receivedAt,
platform: platform ?? this.platform,
sourceDisplayName: sourceDisplayName ?? this.sourceDisplayName,
sourceUserId: sourceUserId ?? this.sourceUserId,
sourceThreadId: sourceThreadId ?? this.sourceThreadId,
parsingHints: parsingHints ?? this.parsingHints,
metadata: metadata ?? this.metadata,
dedupeKey: dedupeKey ?? this.dedupeKey,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'sourceApp': sourceApp,
'payloadType': payloadType.name,
'rawText': rawText,
'url': url,
'createdAt': createdAt.toUtc().toIso8601String(),
'receivedAt': receivedAt.toUtc().toIso8601String(),
'platform': platform,
'sourceDisplayName': sourceDisplayName,
'sourceUserId': sourceUserId,
'sourceThreadId': sourceThreadId,
'parsingHints': parsingHints,
'metadata': metadata,
'dedupeKey': dedupeKey,
};
}
factory SharedPayload.fromJson(Map<String, dynamic> json) {
final String payloadTypeName =
json['payloadType'] as String? ?? SharedPayloadType.text.name;
return SharedPayload(
sourceApp: json['sourceApp'] as String? ?? 'share_sheet',
payloadType: SharedPayloadType.values.firstWhere(
(SharedPayloadType value) => value.name == payloadTypeName,
orElse: () => SharedPayloadType.text,
),
rawText: json['rawText'] as String? ?? '',
url: json['url'] as String?,
createdAt: DateTime.parse(
json['createdAt'] as String? ??
DateTime.now().toUtc().toIso8601String(),
).toLocal(),
receivedAt: DateTime.parse(
json['receivedAt'] as String? ??
DateTime.now().toUtc().toIso8601String(),
).toLocal(),
platform: json['platform'] as String? ?? 'unknown',
sourceDisplayName: json['sourceDisplayName'] as String?,
sourceUserId: json['sourceUserId'] as String?,
sourceThreadId: json['sourceThreadId'] as String?,
parsingHints: (json['parsingHints'] as List<dynamic>? ?? <dynamic>[])
.map((dynamic item) => '$item')
.toList(growable: false),
metadata:
(json['metadata'] as Map<dynamic, dynamic>? ?? <dynamic, dynamic>{})
.map(
(dynamic key, dynamic value) =>
MapEntry<String, String>('$key', '$value'),
),
dedupeKey: json['dedupeKey'] as String?,
);
}
}
/// Draft extracted from a shared payload before it becomes a persistent fact.
@immutable
class CapturedFactDraft {
const CapturedFactDraft({
required this.type,
required this.text,
this.label,
this.dateValue,
this.confidence,
this.isSensitive = false,
this.needsReview = false,
});
final CapturedFactType type;
final String text;
final String? label;
final DateTime? dateValue;
final double? confidence;
final bool isSensitive;
final bool needsReview;
CapturedFactDraft copyWith({
CapturedFactType? type,
String? text,
String? label,
DateTime? dateValue,
double? confidence,
bool? isSensitive,
bool? needsReview,
}) {
return CapturedFactDraft(
type: type ?? this.type,
text: text ?? this.text,
label: label ?? this.label,
dateValue: dateValue ?? this.dateValue,
confidence: confidence ?? this.confidence,
isSensitive: isSensitive ?? this.isSensitive,
needsReview: needsReview ?? this.needsReview,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'type': type.name,
'text': text,
'label': label,
'dateValue': dateValue?.toUtc().toIso8601String(),
'confidence': confidence,
'isSensitive': isSensitive,
'needsReview': needsReview,
};
}
factory CapturedFactDraft.fromJson(Map<String, dynamic> json) {
final String typeName =
json['type'] as String? ?? CapturedFactType.note.name;
return CapturedFactDraft(
type: CapturedFactType.values.firstWhere(
(CapturedFactType value) => value.name == typeName,
orElse: () => CapturedFactType.note,
),
text: json['text'] as String? ?? '',
label: json['label'] as String?,
dateValue: json['dateValue'] == null
? null
: DateTime.parse(json['dateValue'] as String).toLocal(),
confidence: (json['confidence'] as num?)?.toDouble(),
isSensitive: json['isSensitive'] as bool? ?? false,
needsReview: json['needsReview'] as bool? ?? false,
);
}
}
/// Durable record of a share that was already attached to a person.
@immutable
class SharedMessageEntry {
const SharedMessageEntry({
required this.id,
required this.profileId,
required this.payload,
required this.importedAt,
required this.resolvedAutomatically,
this.assignmentStatus = ShareAssignmentStatus.assignedDirectly,
this.draft,
});
final String id;
final String profileId;
final SharedPayload payload;
final DateTime importedAt;
final bool resolvedAutomatically;
final ShareAssignmentStatus assignmentStatus;
final CapturedFactDraft? draft;
String get sourceApp => payload.sourceApp;
String get messageText => payload.rawText;
DateTime get sharedAt => payload.createdAt;
SharedPayloadType get payloadType => payload.payloadType;
String? get url => payload.url;
String? get sourceDisplayName => payload.sourceDisplayName;
String? get sourceUserId => payload.sourceUserId;
String? get sourceThreadId => payload.sourceThreadId;
SharedMessageEntry copyWith({
String? id,
String? profileId,
SharedPayload? payload,
DateTime? importedAt,
bool? resolvedAutomatically,
ShareAssignmentStatus? assignmentStatus,
CapturedFactDraft? draft,
}) {
return SharedMessageEntry(
id: id ?? this.id,
profileId: profileId ?? this.profileId,
payload: payload ?? this.payload,
importedAt: importedAt ?? this.importedAt,
resolvedAutomatically:
resolvedAutomatically ?? this.resolvedAutomatically,
assignmentStatus: assignmentStatus ?? this.assignmentStatus,
draft: draft ?? this.draft,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'profileId': profileId,
'payload': payload.toJson(),
'importedAt': importedAt.toUtc().toIso8601String(),
'resolvedAutomatically': resolvedAutomatically,
'assignmentStatus': assignmentStatus.name,
'draft': draft?.toJson(),
};
}
factory SharedMessageEntry.fromJson(Map<String, dynamic> json) {
final String assignmentStatusName =
json['assignmentStatus'] as String? ??
ShareAssignmentStatus.assignedDirectly.name;
final SharedPayload payload = json['payload'] is Map<String, dynamic>
? SharedPayload.fromJson(json['payload'] as Map<String, dynamic>)
: SharedPayload(
sourceApp: json['sourceApp'] as String? ?? 'share_sheet',
payloadType: SharedPayloadType.text,
rawText: json['messageText'] as String? ?? '',
createdAt: DateTime.parse(
json['sharedAt'] as String? ??
DateTime.now().toUtc().toIso8601String(),
).toLocal(),
receivedAt: DateTime.parse(
json['importedAt'] as String? ??
DateTime.now().toUtc().toIso8601String(),
).toLocal(),
platform: 'legacy',
sourceDisplayName: json['sourceDisplayName'] as String?,
sourceUserId: json['sourceUserId'] as String?,
sourceThreadId: json['sourceThreadId'] as String?,
);
return SharedMessageEntry(
id: json['id'] as String,
profileId: json['profileId'] as String,
payload: payload,
importedAt: DateTime.parse(json['importedAt'] as String).toLocal(),
resolvedAutomatically: json['resolvedAutomatically'] as bool? ?? true,
assignmentStatus: ShareAssignmentStatus.values.firstWhere(
(ShareAssignmentStatus value) => value.name == assignmentStatusName,
orElse: () => ShareAssignmentStatus.assignedDirectly,
),
draft: json['draft'] is Map<String, dynamic>
? CapturedFactDraft.fromJson(json['draft'] as Map<String, dynamic>)
: null,
);
}
}
/// Why the share flow parked a payload in the inbox instead of saving directly.
enum SharedInboxReason {
ambiguousProfileMatch,
nearProfileConflict,
missingIdentity,
}
/// Unresolved share payload waiting for manual triage.
@immutable
class SharedInboxEntry {
const SharedInboxEntry({
required this.id,
required this.payload,
required this.reason,
required this.candidateProfileIds,
required this.normalizedDisplayName,
this.assignmentStatus = ShareAssignmentStatus.needsPersonSelection,
this.targetPersonId,
this.sourceFingerprint,
this.draft,
});
final String id;
final SharedPayload payload;
final SharedInboxReason reason;
final List<String> candidateProfileIds;
final String normalizedDisplayName;
final ShareAssignmentStatus assignmentStatus;
final String? targetPersonId;
final String? sourceFingerprint;
final CapturedFactDraft? draft;
String get sourceApp => payload.sourceApp;
String get messageText => payload.rawText;
DateTime get sharedAt => payload.createdAt;
DateTime get receivedAt => payload.receivedAt;
SharedPayloadType get payloadType => payload.payloadType;
String? get url => payload.url;
String? get sourceDisplayName => payload.sourceDisplayName;
String? get sourceUserId => payload.sourceUserId;
String? get sourceThreadId => payload.sourceThreadId;
SharedInboxEntry copyWith({
String? id,
SharedPayload? payload,
SharedInboxReason? reason,
List<String>? candidateProfileIds,
String? normalizedDisplayName,
ShareAssignmentStatus? assignmentStatus,
String? targetPersonId,
String? sourceFingerprint,
CapturedFactDraft? draft,
}) {
return SharedInboxEntry(
id: id ?? this.id,
payload: payload ?? this.payload,
reason: reason ?? this.reason,
candidateProfileIds: candidateProfileIds ?? this.candidateProfileIds,
normalizedDisplayName:
normalizedDisplayName ?? this.normalizedDisplayName,
assignmentStatus: assignmentStatus ?? this.assignmentStatus,
targetPersonId: targetPersonId ?? this.targetPersonId,
sourceFingerprint: sourceFingerprint ?? this.sourceFingerprint,
draft: draft ?? this.draft,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'payload': payload.toJson(),
'reason': reason.name,
'candidateProfileIds': candidateProfileIds,
'normalizedDisplayName': normalizedDisplayName,
'assignmentStatus': assignmentStatus.name,
'targetPersonId': targetPersonId,
'sourceFingerprint': sourceFingerprint,
'draft': draft?.toJson(),
};
}
factory SharedInboxEntry.fromJson(Map<String, dynamic> json) {
final String reasonName =
json['reason'] as String? ?? SharedInboxReason.missingIdentity.name;
final String assignmentStatusName =
json['assignmentStatus'] as String? ??
ShareAssignmentStatus.needsPersonSelection.name;
final SharedPayload payload = json['payload'] is Map<String, dynamic>
? SharedPayload.fromJson(json['payload'] as Map<String, dynamic>)
: SharedPayload(
sourceApp: json['sourceApp'] as String? ?? 'share_sheet',
payloadType: SharedPayloadType.text,
rawText: json['messageText'] as String? ?? '',
createdAt: DateTime.parse(
json['sharedAt'] as String? ??
DateTime.now().toUtc().toIso8601String(),
).toLocal(),
receivedAt: DateTime.parse(
json['receivedAt'] as String? ??
DateTime.now().toUtc().toIso8601String(),
).toLocal(),
platform: 'legacy',
sourceDisplayName: json['sourceDisplayName'] as String?,
sourceUserId: json['sourceUserId'] as String?,
sourceThreadId: json['sourceThreadId'] as String?,
);
return SharedInboxEntry(
id: json['id'] as String,
payload: payload,
reason: SharedInboxReason.values.firstWhere(
(SharedInboxReason item) => item.name == reasonName,
orElse: () => SharedInboxReason.missingIdentity,
),
candidateProfileIds:
(json['candidateProfileIds'] as List<dynamic>? ?? <dynamic>[])
.map((dynamic id) => '$id')
.toList(growable: false),
normalizedDisplayName: json['normalizedDisplayName'] as String? ?? '',
assignmentStatus: ShareAssignmentStatus.values.firstWhere(
(ShareAssignmentStatus value) => value.name == assignmentStatusName,
orElse: () => ShareAssignmentStatus.needsPersonSelection,
),
targetPersonId: json['targetPersonId'] as String?,
sourceFingerprint: json['sourceFingerprint'] as String?,
draft: json['draft'] is Map<String, dynamic>
? CapturedFactDraft.fromJson(json['draft'] as Map<String, dynamic>)
: null,
);
}
}
@@ -0,0 +1,178 @@
import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/share_intake/domain/signal_share_parser.dart';
import 'package:relationship_saver/features/share_intake/whatsapp_share_parser.dart';
class SharePayloadParser {
const SharePayloadParser._();
static final RegExp _urlPattern = RegExp(
r'(https?:\/\/[^\s]+|www\.[^\s]+)',
caseSensitive: false,
);
static SharedPayload parse({
required String rawText,
required String sourceApp,
required String platform,
DateTime? createdAt,
DateTime? receivedAt,
}) {
final DateTime now = DateTime.now();
final String normalizedSourceApp = _normalizeSourceApp(sourceApp);
final String trimmed = rawText.trim();
final _ParsedSourceMetadata? sourceMetadata = _parseSourceMetadata(
sourceApp: normalizedSourceApp,
rawText: trimmed,
);
final String candidateText = sourceMetadata?.messageText.trim() ?? trimmed;
final RegExpMatch? urlMatch = _urlPattern.firstMatch(candidateText);
final String? url = urlMatch == null
? null
: _normalizeUrl(urlMatch.group(0));
final String cleanedText = url == null
? candidateText
: candidateText.replaceFirst(urlMatch!.group(0)!, '').trim();
final SharedPayloadType payloadType;
if (url != null && cleanedText.isNotEmpty) {
payloadType = SharedPayloadType.textWithUrl;
} else if (url != null) {
payloadType = SharedPayloadType.url;
} else {
payloadType = SharedPayloadType.text;
}
return SharedPayload(
sourceApp: normalizedSourceApp,
payloadType: payloadType,
rawText: cleanedText,
url: url,
createdAt: createdAt ?? now,
receivedAt: receivedAt ?? now,
platform: platform,
sourceDisplayName: sourceMetadata?.sourceDisplayName,
sourceUserId: sourceMetadata?.sourceUserId,
sourceThreadId: sourceMetadata?.sourceThreadId,
parsingHints: <String>[
if (normalizedSourceApp != 'share_sheet')
'source_app_$normalizedSourceApp',
if (url != null) 'contains_url',
if (sourceMetadata?.sourceDisplayName case final String _)
'sender_detected',
],
metadata: <String, String>{
if (url case final String value) 'primaryUrl': value,
},
);
}
static String detectSourceApp({
required String rawText,
String? sourceAppHint,
}) {
final String normalizedHint = _normalizeSourceApp(sourceAppHint);
if (normalizedHint != 'share_sheet') {
return normalizedHint;
}
final String trimmed = rawText.trim();
if (_shouldUseWhatsAppParser(normalizedHint, trimmed)) {
return 'whatsapp';
}
return 'share_sheet';
}
static bool _shouldUseWhatsAppParser(String sourceApp, String rawText) {
if (sourceApp.contains('whatsapp')) {
return true;
}
if (sourceApp != 'share_sheet') {
return false;
}
if (rawText.contains(': ') && rawText.length < 4000) {
return WhatsAppShareParser.looksLikeWhatsAppMessage(rawText);
}
return false;
}
static bool _shouldUseSignalParser(String sourceApp) {
return sourceApp.contains('signal');
}
static _ParsedSourceMetadata? _parseSourceMetadata({
required String sourceApp,
required String rawText,
}) {
if (_shouldUseWhatsAppParser(sourceApp, rawText)) {
final WhatsAppSharePayload payload = WhatsAppShareParser.parse(rawText);
return _ParsedSourceMetadata(
messageText: payload.messageText,
sourceDisplayName: payload.sourceDisplayName,
sourceUserId: payload.sourceUserId,
sourceThreadId: payload.sourceThreadId,
);
}
if (_shouldUseSignalParser(sourceApp) &&
SignalShareParser.looksLikeSignalMessage(rawText)) {
final SignalSharePayload payload = SignalShareParser.parse(rawText);
return _ParsedSourceMetadata(
messageText: payload.messageText,
sourceDisplayName: payload.sourceDisplayName,
sourceUserId: payload.sourceUserId,
sourceThreadId: payload.sourceThreadId,
);
}
return null;
}
static String _normalizeSourceApp(String? raw) {
final String value = raw?.trim().toLowerCase() ?? '';
if (value.isEmpty ||
value == 'generic' ||
value == 'text' ||
value == 'share sheet') {
return 'share_sheet';
}
if (value.contains('whatsapp')) {
return 'whatsapp';
}
if (value.contains('signal')) {
return 'signal';
}
if (value.contains('note')) {
return 'notes';
}
return value;
}
static String? _normalizeUrl(String? raw) {
if (raw == null) {
return null;
}
final String value = raw.trim();
if (value.isEmpty) {
return null;
}
if (value.toLowerCase().startsWith('http://') ||
value.toLowerCase().startsWith('https://')) {
return value;
}
return 'https://$value';
}
}
class _ParsedSourceMetadata {
const _ParsedSourceMetadata({
required this.messageText,
this.sourceDisplayName,
this.sourceUserId,
this.sourceThreadId,
});
final String messageText;
final String? sourceDisplayName;
final String? sourceUserId;
final String? sourceThreadId;
}
@@ -0,0 +1,72 @@
class SignalSharePayload {
const SignalSharePayload({
required this.messageText,
this.sourceDisplayName,
this.sourceUserId,
this.sourceThreadId,
});
final String messageText;
final String? sourceDisplayName;
final String? sourceUserId;
final String? sourceThreadId;
}
/// Parses raw shared text from Signal into normalized sender/message fields.
class SignalShareParser {
const SignalShareParser._();
static final RegExp _namePrefixPattern = RegExp(
r'^([^:\n]{2,80}):\s*(.+)$',
dotAll: true,
);
static SignalSharePayload parse(String raw) {
final String input = raw.trim();
if (input.isEmpty) {
return const SignalSharePayload(messageText: '');
}
final RegExpMatch? match = _namePrefixPattern.firstMatch(input);
if (match == null) {
return SignalSharePayload(messageText: input);
}
final String? senderName = _cleanSender(match.group(1));
final String messageText = match.group(2)?.trim() ?? input;
return SignalSharePayload(
messageText: messageText,
sourceDisplayName: senderName,
sourceUserId: _normalizedKey(senderName),
sourceThreadId: null,
);
}
static bool looksLikeSignalMessage(String raw) {
final String input = raw.trim();
if (input.isEmpty) {
return false;
}
return _namePrefixPattern.hasMatch(input);
}
static String? _cleanSender(String? input) {
if (input == null) {
return null;
}
final String cleaned = input.trim();
return cleaned.isEmpty ? null : cleaned;
}
static String? _normalizedKey(String? input) {
if (input == null) {
return null;
}
final String key = input
.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9]+'), ' ')
.trim()
.replaceAll(RegExp(r'\s+'), ' ');
return key.isEmpty ? null : 'signal:$key';
}
}
@@ -0,0 +1,87 @@
class WhatsAppSharePayload {
const WhatsAppSharePayload({
required this.messageText,
this.sourceDisplayName,
this.sourceUserId,
this.sourceThreadId,
});
final String messageText;
final String? sourceDisplayName;
final String? sourceUserId;
final String? sourceThreadId;
}
/// Parses raw shared text from WhatsApp into normalized fields.
class WhatsAppShareParser {
const WhatsAppShareParser._();
static final RegExp _datePrefixPattern = RegExp(
r'^\[[^\]]+\]\s*([^:\n]{2,80}):\s*(.+)$',
dotAll: true,
);
static final RegExp _namePrefixPattern = RegExp(
r'^([^:\n]{2,80}):\s*(.+)$',
dotAll: true,
);
static WhatsAppSharePayload parse(String raw) {
final String input = raw.trim();
if (input.isEmpty) {
return const WhatsAppSharePayload(messageText: '');
}
String? senderName;
String messageText = input;
final RegExpMatch? dateMatch = _datePrefixPattern.firstMatch(input);
if (dateMatch != null) {
senderName = _cleanSender(dateMatch.group(1));
messageText = dateMatch.group(2)?.trim() ?? input;
} else {
final RegExpMatch? nameMatch = _namePrefixPattern.firstMatch(input);
if (nameMatch != null) {
senderName = _cleanSender(nameMatch.group(1));
messageText = nameMatch.group(2)?.trim() ?? input;
}
}
final String? sourceUserId = _normalizedKey(senderName);
return WhatsAppSharePayload(
messageText: messageText,
sourceDisplayName: senderName,
sourceUserId: sourceUserId,
sourceThreadId: null,
);
}
static bool looksLikeWhatsAppMessage(String raw) {
final String input = raw.trim();
if (input.isEmpty) {
return false;
}
return _datePrefixPattern.hasMatch(input) ||
_namePrefixPattern.hasMatch(input);
}
static String? _cleanSender(String? input) {
if (input == null) {
return null;
}
final String cleaned = input.trim();
return cleaned.isEmpty ? null : cleaned;
}
static String? _normalizedKey(String? input) {
if (input == null) {
return null;
}
final String key = input
.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9]+'), ' ')
.trim()
.replaceAll(RegExp(r'\s+'), ' ');
return key.isEmpty ? null : 'wa:$key';
}
}
@@ -0,0 +1,4 @@
# Share Intake Presentation
The listener widgets, review sheet, and inbox UI live here. Use this folder when
you want to change the share-capture UX without touching parsing or persistence.
@@ -0,0 +1,176 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:relationship_saver/core/config/app_config.dart';
import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/share_intake/share_capture_flow.dart';
import 'package:relationship_saver/features/share_intake/share_capture_models.dart';
/// Listens for native share intents and routes incoming content through review.
class IncomingShareListener extends ConsumerStatefulWidget {
const IncomingShareListener({required this.child, super.key});
final Widget child;
@override
ConsumerState<IncomingShareListener> createState() =>
_IncomingShareListenerState();
}
class _IncomingShareListenerState extends ConsumerState<IncomingShareListener> {
StreamSubscription<List<SharedMediaFile>>? _mediaSubscription;
String? _lastToken;
@override
void initState() {
super.initState();
_start();
}
@override
void dispose() {
_mediaSubscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
Future<void> _start() async {
if (!AppConfig.enableWhatsAppShareIntake || kIsWeb) {
return;
}
try {
_mediaSubscription = ReceiveSharingIntent.instance
.getMediaStream()
.listen(
(List<SharedMediaFile> files) =>
_handleMediaList(files, autoOpenDestination: false),
onError: (_) {},
);
final List<SharedMediaFile> initialMedia = await ReceiveSharingIntent
.instance
.getInitialMedia();
if (initialMedia.isNotEmpty) {
await _handleMediaList(initialMedia, autoOpenDestination: true);
}
} catch (_) {
// Unsupported platform/plugin state should not break app usage.
}
}
Future<void> _handleMediaList(
List<SharedMediaFile> files, {
required bool autoOpenDestination,
}) async {
for (final SharedMediaFile file in files) {
final String? rawText = _extractText(file);
if (rawText == null) {
continue;
}
await _handleRawText(
rawText,
autoOpenDestination: autoOpenDestination,
sourceAppHint: _inferSourceAppHint(file, rawText),
);
}
}
String? _extractText(SharedMediaFile file) {
if (file.type == SharedMediaType.text) {
final String text = file.path.trim();
if (text.isNotEmpty) {
return text;
}
}
final String? mimeType = file.mimeType?.toLowerCase();
if (mimeType != null && mimeType.startsWith('text/')) {
final String text = file.path.trim();
if (text.isNotEmpty) {
return text;
}
}
final String? message = file.message?.trim();
if (message != null && message.isNotEmpty) {
return message;
}
return null;
}
String? _inferSourceAppHint(SharedMediaFile file, String rawText) {
final String? mimeType = file.mimeType?.toLowerCase();
if (mimeType == 'text/plain' && rawText.contains('Notes')) {
return 'notes';
}
return null;
}
Future<void> _handleRawText(
String rawText, {
required bool autoOpenDestination,
String? sourceAppHint,
}) async {
final String trimmed = rawText.trim();
if (trimmed.isEmpty) {
return;
}
final SharedPayload? payload = buildSharedPayloadFromRawText(
rawText: trimmed,
platform: defaultTargetPlatform.name,
sourceAppHint: sourceAppHint,
);
if (payload == null) {
return;
}
final String token =
'${payload.sourceApp}:${payload.sourceUserId ?? payload.sourceDisplayName ?? ''}:${payload.rawText}:${payload.url ?? ''}';
if (_lastToken == token) {
return;
}
_lastToken = token;
if (!mounted) {
return;
}
final SharedMessageIngestResult? result = await startShareCaptureReview(
context,
ref: ref,
payload: payload,
);
if (!mounted || result == null) {
return;
}
if (autoOpenDestination) {
openShareCaptureDestination(context, ref: ref, result: result);
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(shareCaptureResultMessage(result)),
action: SnackBarAction(
label: result.isQueuedForResolution ? 'Open Inbox' : 'Open Profile',
onPressed: () =>
openShareCaptureDestination(context, ref: ref, result: result),
),
),
);
try {
await ReceiveSharingIntent.instance.reset();
} catch (_) {
// Best effort.
}
}
}
@@ -0,0 +1,574 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/local/local_repository.dart';
import 'package:relationship_saver/features/share_intake/domain/share_capture_draft_suggester.dart';
import 'package:relationship_saver/features/share_intake/share_capture_models.dart';
Future<SharedMessageIngestResult?> showShareCaptureReviewSheet(
BuildContext context, {
required WidgetRef ref,
required SharedPayload payload,
String? initialPersonId,
}) {
return showModalBottomSheet<SharedMessageIngestResult>(
context: context,
isScrollControlled: true,
useSafeArea: true,
showDragHandle: true,
builder: (BuildContext context) {
return _ShareCaptureReviewSheet(
payload: payload,
initialPersonId: initialPersonId,
);
},
);
}
class _ShareCaptureReviewSheet extends ConsumerStatefulWidget {
const _ShareCaptureReviewSheet({required this.payload, this.initialPersonId});
final SharedPayload payload;
final String? initialPersonId;
@override
ConsumerState<_ShareCaptureReviewSheet> createState() =>
_ShareCaptureReviewSheetState();
}
class _ShareCaptureReviewSheetState
extends ConsumerState<_ShareCaptureReviewSheet> {
late final TextEditingController _textController;
late final TextEditingController _labelController;
late CapturedFactType _type;
String? _selectedPersonId;
DateTime? _selectedDate;
late bool _isSensitive;
bool _submitting = false;
@override
void initState() {
super.initState();
final CapturedFactDraft suggestedDraft =
ShareCaptureDraftSuggester.suggestForPayload(widget.payload);
_selectedPersonId = widget.initialPersonId;
_type = suggestedDraft.type;
_selectedDate = suggestedDraft.dateValue;
_isSensitive = suggestedDraft.isSensitive;
_textController = TextEditingController(text: suggestedDraft.text);
_labelController = TextEditingController(text: suggestedDraft.label ?? '');
}
@override
void dispose() {
_textController.dispose();
_labelController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final AsyncValue<LocalDataState> localData = ref.watch(
localRepositoryProvider,
);
return localData.when(
data: (LocalDataState state) {
final List<PersonProfile> people = state.people.toList(growable: false)
..sort(_recentPeopleSort);
return Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 8,
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Save Shared Capture',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 6),
Text(
'Review the incoming text or link, then assign it safely.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppTheme.textSecondary,
),
),
const SizedBox(height: 14),
_PayloadPreview(payload: widget.payload),
const SizedBox(height: 14),
Text(
'What kind of info is this?',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: CapturedFactType.values
.map((CapturedFactType type) {
return ChoiceChip(
label: Text(_factTypeLabel(type)),
selected: _type == type,
onSelected: (_) {
setState(() {
_type = type;
});
},
);
})
.toList(growable: false),
),
const SizedBox(height: 14),
TextField(
controller: _textController,
minLines: 2,
maxLines: 4,
decoration: InputDecoration(
labelText: _type == CapturedFactType.importantDate
? 'Date note'
: 'Captured text',
hintText:
'Adjust the note, preference, or idea before saving.',
),
),
const SizedBox(height: 10),
TextField(
controller: _labelController,
decoration: InputDecoration(
labelText: _type == CapturedFactType.importantDate
? 'Date label'
: 'Optional label',
),
),
if (_type == CapturedFactType.importantDate) ...<Widget>[
const SizedBox(height: 10),
OutlinedButton.icon(
onPressed: _pickDate,
icon: const Icon(Icons.event_outlined),
label: Text(
_selectedDate == null
? 'Choose date'
: 'Date: ${_dateLabel(_selectedDate!)}',
),
),
],
const SizedBox(height: 12),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text('Mark as sensitive'),
subtitle: const Text(
'Useful for future privacy controls and review.',
),
value: _isSensitive,
onChanged: (bool value) {
setState(() {
_isSensitive = value;
});
},
),
const SizedBox(height: 8),
Text(
'Assign to person',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
if (people.isEmpty)
Text(
'No people yet. Create one or save this to the inbox.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppTheme.textSecondary,
),
)
else
Wrap(
spacing: 8,
runSpacing: 8,
children: people
.take(6)
.map((PersonProfile person) {
return ChoiceChip(
label: Text(person.name),
selected: _selectedPersonId == person.id,
onSelected: (_) {
setState(() {
_selectedPersonId = person.id;
});
},
);
})
.toList(growable: false),
),
if (people.isNotEmpty) ...<Widget>[
const SizedBox(height: 10),
DropdownButtonFormField<String>(
initialValue: _selectedPersonId,
decoration: const InputDecoration(
labelText: 'Or choose from all people',
),
items: people
.map(
(PersonProfile person) => DropdownMenuItem<String>(
value: person.id,
child: Text(
'${person.name}${person.relationship}',
),
),
)
.toList(growable: false),
onChanged: (String? value) {
setState(() {
_selectedPersonId = value;
});
},
),
],
const SizedBox(height: 16),
Row(
children: <Widget>[
Expanded(
child: OutlinedButton(
onPressed: _submitting ? null : _saveToInbox,
child: const Text('Save To Inbox'),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton.tonal(
onPressed: _submitting ? null : _createPerson,
child: const Text('Create Person'),
),
),
],
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _submitting || _selectedPersonId == null
? null
: _saveToSelectedPerson,
child: const Text('Save To Person'),
),
),
],
),
),
);
},
loading: () => const Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator()),
),
error: (_, _) => const Padding(
padding: EdgeInsets.all(24),
child: Text('Unable to load people for share review.'),
),
);
}
int _recentPeopleSort(PersonProfile a, PersonProfile b) {
final DateTime? left = a.lastInteractedAt ?? a.lastUpdatedAt;
final DateTime? right = b.lastInteractedAt ?? b.lastUpdatedAt;
if (left == null && right != null) {
return 1;
}
if (left != null && right == null) {
return -1;
}
if (left != null && right != null) {
final int byDate = right.compareTo(left);
if (byDate != 0) {
return byDate;
}
}
return b.affinityScore.compareTo(a.affinityScore);
}
CapturedFactDraft _buildDraft() {
return CapturedFactDraft(
type: _type,
text: _textController.text.trim(),
label: _labelController.text.trim().isEmpty
? null
: _labelController.text.trim(),
dateValue: _selectedDate,
isSensitive: _isSensitive,
needsReview: false,
);
}
Future<void> _saveToSelectedPerson() async {
final String? personId = _selectedPersonId;
if (personId == null) {
return;
}
await _runAction(() async {
final SharedMessageIngestResult result = await ref
.read(localRepositoryProvider.notifier)
.captureSharedPayloadToExistingProfile(
payload: widget.payload,
profileId: personId,
draft: _buildDraft(),
resolvedAutomatically: false,
);
if (!mounted) {
return;
}
Navigator.of(context).pop(result);
});
}
Future<void> _saveToInbox() async {
await _runAction(() async {
final SharedMessageIngestResult result = await ref
.read(localRepositoryProvider.notifier)
.saveSharedPayloadToInbox(
payload: widget.payload,
draft: _buildDraft(),
targetPersonId: _selectedPersonId,
);
if (!mounted) {
return;
}
Navigator.of(context).pop(result);
});
}
Future<void> _createPerson() async {
final _CreatePersonFromShareDraft? draft =
await showDialog<_CreatePersonFromShareDraft>(
context: context,
builder: (BuildContext context) => _CreatePersonFromShareDialog(
initialName:
widget.payload.sourceDisplayName ??
widget.payload.rawText.split(RegExp(r'\s+')).take(2).join(' '),
),
);
if (draft == null) {
return;
}
await _runAction(() async {
final SharedMessageIngestResult result = await ref
.read(localRepositoryProvider.notifier)
.captureSharedPayloadByCreatingProfile(
payload: widget.payload,
name: draft.name,
relationship: draft.relationship,
aliases: draft.aliases,
draft: _buildDraft(),
);
if (!mounted) {
return;
}
Navigator.of(context).pop(result);
});
}
Future<void> _pickDate() async {
final DateTime now = DateTime.now();
final DateTime? date = await showDatePicker(
context: context,
firstDate: DateTime(now.year - 5),
lastDate: DateTime(now.year + 10),
initialDate: _selectedDate ?? now,
);
if (date == null) {
return;
}
setState(() {
_selectedDate = date;
});
}
Future<void> _runAction(Future<void> Function() action) async {
setState(() {
_submitting = true;
});
try {
await action();
} finally {
if (mounted) {
setState(() {
_submitting = false;
});
}
}
}
}
String _factTypeLabel(CapturedFactType type) {
return switch (type) {
CapturedFactType.note => 'Note',
CapturedFactType.like => 'Like',
CapturedFactType.dislike => 'Dislike',
CapturedFactType.importantDate => 'Date',
CapturedFactType.giftIdea => 'Gift',
CapturedFactType.placeIdea => 'Place',
CapturedFactType.activityIdea => 'Activity',
CapturedFactType.misc => 'Misc',
};
}
String _dateLabel(DateTime value) {
return '${value.year}-${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')}';
}
class _PayloadPreview extends StatelessWidget {
const _PayloadPreview({required this.payload});
final SharedPayload payload;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE5EDF2)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
payload.sourceApp.toUpperCase(),
style: Theme.of(
context,
).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary),
),
if (payload.sourceDisplayName != null) ...<Widget>[
const SizedBox(height: 4),
Text(
'Sender: ${payload.sourceDisplayName}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary),
),
],
if (payload.rawText.trim().isNotEmpty) ...<Widget>[
const SizedBox(height: 8),
Text(payload.rawText.trim()),
],
if (payload.url != null) ...<Widget>[
const SizedBox(height: 8),
SelectableText(
payload.url!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppTheme.primary,
decoration: TextDecoration.underline,
),
),
],
],
),
);
}
}
class _CreatePersonFromShareDraft {
const _CreatePersonFromShareDraft({
required this.name,
required this.relationship,
required this.aliases,
});
final String name;
final String relationship;
final List<String> aliases;
}
class _CreatePersonFromShareDialog extends StatefulWidget {
const _CreatePersonFromShareDialog({required this.initialName});
final String initialName;
@override
State<_CreatePersonFromShareDialog> createState() =>
_CreatePersonFromShareDialogState();
}
class _CreatePersonFromShareDialogState
extends State<_CreatePersonFromShareDialog> {
late final TextEditingController _nameController;
final TextEditingController _relationshipController = TextEditingController(
text: 'Contact',
);
final TextEditingController _aliasesController = TextEditingController();
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.initialName);
}
@override
void dispose() {
_nameController.dispose();
_relationshipController.dispose();
_aliasesController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Create Person'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
TextField(
controller: _nameController,
decoration: const InputDecoration(labelText: 'Name'),
),
const SizedBox(height: 10),
TextField(
controller: _relationshipController,
decoration: const InputDecoration(labelText: 'Relationship'),
),
const SizedBox(height: 10),
TextField(
controller: _aliasesController,
decoration: const InputDecoration(
labelText: 'Aliases (comma separated)',
),
),
],
),
),
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;
}
Navigator.of(context).pop(
_CreatePersonFromShareDraft(
name: name,
relationship: relationship,
aliases: _aliasesController.text
.split(',')
.map((String value) => value.trim())
.where((String value) => value.isNotEmpty)
.toList(growable: false),
),
);
},
child: const Text('Create'),
),
],
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
// Legacy compatibility export for the generic incoming share listener.
export 'package:relationship_saver/features/share_intake/presentation/incoming_share_listener.dart';
@@ -0,0 +1,2 @@
// Legacy compatibility export for the share capture application flow.
export 'package:relationship_saver/features/share_intake/application/share_capture_flow.dart';
@@ -0,0 +1,2 @@
// Legacy compatibility export for share capture flow contracts.
export 'package:relationship_saver/features/share_intake/application/share_capture_models.dart';
@@ -0,0 +1,2 @@
// Legacy compatibility export for the share capture review presentation.
export 'package:relationship_saver/features/share_intake/presentation/share_capture_review_sheet.dart';

Some files were not shown because too many files have changed in this diff Show More