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:
@@ -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();
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user