feat: Implement snooze functionality for notifications

- Added snooze duration setting in SettingsScreen.
- Created DebugNotificationsScreen to view pending notifications and logs.
- Integrated notification logging with NotificationDebugStore.
- Enhanced SimpleNotificationService to handle snooze actions and log notifications.
- Removed ProfileSetupScreen as it is no longer needed.
- Updated NotificationRouter to manage snooze actions without UI.
- Refactored settings provider to include snooze duration management.
This commit is contained in:
2025-08-30 01:51:38 +02:00
parent 811c1f3d6a
commit f7966ce587
8 changed files with 940 additions and 183 deletions

View File

@@ -0,0 +1,415 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:provider/provider.dart';
import 'package:supplements/providers/supplement_provider.dart';
import 'package:supplements/services/notification_debug_store.dart';
import 'package:supplements/services/simple_notification_service.dart';
class DebugNotificationsScreen extends StatefulWidget {
const DebugNotificationsScreen({super.key});
@override
State<DebugNotificationsScreen> createState() => _DebugNotificationsScreenState();
}
class _DebugNotificationsScreenState extends State<DebugNotificationsScreen> {
bool _loading = true;
List<PendingNotificationRequest> _pending = const [];
List<NotificationLogEntry> _logEntries = const [];
final Map<int, String> _supplementNameCache = {};
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_loading = true;
});
// Fetch pending from plugin
final pending = await SimpleNotificationService.instance.getPendingNotifications();
// Fetch log from local store
final logs = await NotificationDebugStore.instance.getAll();
// Optionally resolve supplement names for single payloads
await _resolveSupplementNames(pending, logs);
if (!mounted) return;
setState(() {
_pending = pending;
_logEntries = logs.reversed.toList(); // newest first
_loading = false;
});
}
Future<void> _resolveSupplementNames(
List<PendingNotificationRequest> pending,
List<NotificationLogEntry> logs,
) async {
final ctx = context;
if (!mounted) return;
final provider = Provider.of<SupplementProvider>(ctx, listen: false);
// Use existing list; attempt load if empty
if (provider.supplements.isEmpty) {
try {
await provider.loadSupplements();
} catch (_) {
// ignore
}
}
// Collect potential IDs
final Set<int> ids = {};
for (final p in pending) {
final id = _extractSingleIdFromPayload(p.payload);
if (id != null) ids.add(id);
}
for (final e in logs) {
if (e.singleId != null) ids.add(e.singleId!);
}
// Build cache
for (final id in ids) {
try {
final s = provider.supplements.firstWhere((el) => el.id == id);
_supplementNameCache[id] = s.name;
} catch (_) {
// leave missing
}
}
}
int? _extractSingleIdFromPayload(String? payload) {
if (payload == null || payload.isEmpty) return null;
try {
final map = jsonDecode(payload);
if (map is Map && map['type'] == 'single') {
final v = map['id'];
if (v is int) return v;
}
} catch (_) {
// ignore
}
return null;
}
String? _extractGroupTimeFromPayload(String? payload) {
if (payload == null || payload.isEmpty) return null;
try {
final map = jsonDecode(payload);
if (map is Map && map['type'] == 'group') {
final v = map['time'];
if (v is String) return v;
}
} catch (_) {
// ignore
}
return null;
}
String _extractKindFromPayload(String? payload) {
if (payload == null || payload.isEmpty) return 'unknown';
try {
final map = jsonDecode(payload);
if (map is Map) {
final meta = map['meta'];
if (meta is Map && meta['kind'] is String) return meta['kind'] as String;
}
} catch (_) {}
return 'unknown';
}
DateTime? _estimateScheduledAtFromPayload(String? payload) {
// For snooze with meta.createdAt/delayMin we can compute when.
if (payload == null || payload.isEmpty) return null;
try {
final map = jsonDecode(payload);
if (map is Map) {
final meta = map['meta'];
if (meta is Map) {
final kind = meta['kind'];
if (kind == 'snooze') {
final createdAt = meta['createdAt'];
final delayMin = meta['delayMin'];
if (createdAt is int && delayMin is int) {
return DateTime.fromMillisecondsSinceEpoch(createdAt)
.add(Duration(minutes: delayMin));
}
}
}
// For daily group we can compute next HH:mm
if (map['type'] == 'group' && map['time'] is String) {
final timeKey = map['time'] as String;
final parts = timeKey.split(':');
if (parts.length == 2) {
final hour = int.tryParse(parts[0]) ?? 0;
final minute = int.tryParse(parts[1]) ?? 0;
final now = DateTime.now();
DateTime sched = DateTime(now.year, now.month, now.day, hour, minute);
if (!sched.isAfter(now)) {
sched = sched.add(const Duration(days: 1));
}
return sched;
}
}
}
} catch (_) {}
return null;
}
Future<void> _cancelId(int id) async {
await SimpleNotificationService.instance.cancelById(id);
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Canceled notification $id')),
);
}
Future<void> _cancelAll() async {
await SimpleNotificationService.instance.cancelAll();
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Canceled all notifications')),
);
}
Future<void> _clearLog() async {
await NotificationDebugStore.instance.clear();
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Cleared debug log')),
);
}
void _copyToClipboard(String text) {
Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Copied to clipboard')),
);
}
@override
Widget build(BuildContext context) {
final pendingIds = _pending.map((e) => e.id).toSet();
return Scaffold(
appBar: AppBar(
title: const Text('Debug Notifications'),
actions: [
IconButton(
tooltip: 'Refresh',
onPressed: _load,
icon: const Icon(Icons.refresh),
),
IconButton(
tooltip: 'Cancel All',
onPressed: _cancelAll,
icon: const Icon(Icons.cancel_schedule_send),
),
IconButton(
tooltip: 'Clear Log',
onPressed: _clearLog,
icon: const Icon(Icons.delete_sweep),
),
],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.all(12),
children: [
_buildPendingSection(),
const SizedBox(height: 16),
_buildTestSnoozeSection(), // Add the test snooze section
const SizedBox(height: 16),
_buildLogSection(pendingIds),
],
),
);
}
Widget _buildPendingSection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Pending (${_pending.length})', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
if (_pending.isEmpty)
const Text('No pending notifications'),
for (final p in _pending) _buildPendingTile(p),
],
),
),
);
}
Widget _buildTestSnoozeSection() {
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Test Snooze', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
ElevatedButton(
onPressed: () async {
// Trigger a test snooze notification with snooze actions
await SimpleNotificationService.instance.showInstant(
title: 'Test Snooze Notification',
body: 'This is a test notification for snooze.',
payload: jsonEncode({
"type": "single",
"id": 1, // Use a dummy ID for testing
"meta": {"kind": "daily"} // Simulate a daily notification
}),
includeSnoozeActions: true, // Include snooze actions
isSingle: true, // This is a single notification
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Test snooze notification sent!')),
);
},
child: const Text('Send Test Snooze Notification'),
),
],
),
),
);
}
Widget _buildPendingTile(PendingNotificationRequest p) {
final kind = _extractKindFromPayload(p.payload);
final singleId = _extractSingleIdFromPayload(p.payload);
final groupTime = _extractGroupTimeFromPayload(p.payload);
final schedAt = _estimateScheduledAtFromPayload(p.payload);
final forStr = singleId != null
? (_supplementNameCache[singleId] != null
? '${_supplementNameCache[singleId]} (id=$singleId)'
: 'Supplement id=$singleId')
: (groupTime != null ? 'Time $groupTime' : 'unknown');
// Build a clearer subtitle with scheduled time prominently displayed
String subtitle = 'Kind: $kind • For: $forStr';
if (schedAt != null) {
final now = DateTime.now();
final diff = schedAt.difference(now);
String whenStr;
if (diff.isNegative) {
whenStr = 'Overdue (${schedAt.toLocal().toString().substring(0, 16)})';
} else if (diff.inDays > 0) {
whenStr = 'In ${diff.inDays}d ${diff.inHours % 24}h (${schedAt.toLocal().toString().substring(0, 16)})';
} else if (diff.inHours > 0) {
whenStr = 'In ${diff.inHours}h ${diff.inMinutes % 60}m (${schedAt.toLocal().toString().substring(11, 16)})';
} else if (diff.inMinutes > 0) {
whenStr = 'In ${diff.inMinutes}m (${schedAt.toLocal().toString().substring(11, 16)})';
} else {
whenStr = 'Very soon (${schedAt.toLocal().toString().substring(11, 16)})';
}
subtitle = '$subtitle\n🕒 $whenStr';
}
// Removed the "else" block that displayed "Schedule time unknown"
return ListTile(
dense: true,
title: Text('ID ${p.id}${p.title ?? "(no title)"}'),
subtitle: Text(subtitle),
trailing: Wrap(
spacing: 4,
children: [
IconButton(
tooltip: 'Copy payload',
icon: const Icon(Icons.copy),
onPressed: () => _copyToClipboard(p.payload ?? ''),
),
IconButton(
tooltip: 'Cancel',
icon: const Icon(Icons.cancel),
onPressed: () => _cancelId(p.id),
),
],
),
);
}
Widget _buildLogSection(Set<int> pendingIds) {
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Schedule Log (${_logEntries.length})', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
if (_logEntries.isEmpty)
const Text('No log entries'),
for (final e in _logEntries) _buildLogTile(e, pendingIds),
],
),
),
);
}
Widget _buildLogTile(NotificationLogEntry e, Set<int> pendingIds) {
final inQueue = pendingIds.contains(e.id);
String forStr = 'unknown';
if (e.type == 'single') {
if (e.singleId != null) {
final name = _supplementNameCache[e.singleId!];
forStr = name != null ? '$name (id=${e.singleId})' : 'id=${e.singleId}';
}
} else if (e.type == 'group') {
if (e.timeKey != null) {
forStr = 'Time ${e.timeKey}';
}
}
final scheduledAt = DateTime.fromMillisecondsSinceEpoch(e.whenEpochMs).toLocal();
final createdAt = DateTime.fromMillisecondsSinceEpoch(e.createdAtEpochMs).toLocal();
// Format times more clearly
final scheduledStr = '${scheduledAt.toString().substring(0, 16)}';
final createdStr = '${createdAt.toString().substring(0, 16)}';
// Show status and timing info
final statusStr = inQueue ? '🟡 Pending' : '✅ Completed/Canceled';
return ListTile(
dense: true,
leading: Icon(inQueue ? Icons.pending : Icons.check, color: inQueue ? Colors.amber : Colors.green),
title: Text('[${e.kind}] ${e.title} (ID ${e.id})'),
subtitle: Text('$statusStr • Type: ${e.type} • For: $forStr\n🕒 Scheduled: $scheduledStr\n📝 Created: $createdStr'),
trailing: Wrap(
spacing: 4,
children: [
IconButton(
tooltip: 'Copy payload',
icon: const Icon(Icons.copy),
onPressed: () => _copyToClipboard(e.payload),
),
if (inQueue)
IconButton(
tooltip: 'Cancel',
icon: const Icon(Icons.cancel),
onPressed: () => _cancelId(e.id),
),
],
),
);
}
}

View File

@@ -1,129 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:supplements/screens/home_screen.dart';
import '../providers/settings_provider.dart';
// Profile setup screen
class ProfileSetupScreen extends StatefulWidget {
const ProfileSetupScreen({super.key});
@override
State<ProfileSetupScreen> createState() => _ProfileSetupScreenState();
}
class _ProfileSetupScreenState extends State<ProfileSetupScreen> {
final _formKey = GlobalKey<FormState>();
DateTime? _dateOfBirth;
String? _gender;
final List<String> _genders = ['Male', 'Female', 'Other', 'Prefer not to say'];
@override
void initState() {
super.initState();
final settingsProvider = Provider.of<SettingsProvider>(context, listen: false);
_dateOfBirth = settingsProvider.dateOfBirth;
_gender = settingsProvider.gender;
}
void _saveProfile() {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
Provider.of<SettingsProvider>(context, listen: false).setDateOfBirthAndGender(_dateOfBirth!, _gender!);
Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (context) => HomeScreen()));
}
}
Future<void> _selectDate(BuildContext context) async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _dateOfBirth ?? DateTime.now(),
firstDate: DateTime(1900),
lastDate: DateTime.now(),
);
if (picked != null && picked != _dateOfBirth) {
setState(() {
_dateOfBirth = picked;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Set Up Your Profile'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
children: [
Text(
'To provide you with personalized ingredient insights, please provide your date of birth and gender.',
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
TextFormField(
decoration: const InputDecoration(
labelText: 'Date of Birth',
border: OutlineInputBorder(),
suffixIcon: Icon(Icons.calendar_today),
),
readOnly: true,
controller: TextEditingController(
text: _dateOfBirth == null
? ''
: '${_dateOfBirth!.toLocal()}'.split(' ')[0],
),
onTap: () => _selectDate(context),
validator: (value) {
if (_dateOfBirth == null) {
return 'Please select your date of birth';
}
return null;
},
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
decoration: const InputDecoration(
labelText: 'Gender',
border: OutlineInputBorder(),
),
value: _gender,
items: _genders.map((String gender) {
return DropdownMenuItem<String>(
value: gender,
child: Text(gender),
);
}).toList(),
onChanged: (value) {
setState(() {
_gender = value;
});
},
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please select your gender';
}
return null;
},
onSaved: (value) {
_gender = value;
},
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _saveProfile,
child: const Text('Save and Continue'),
),
],
),
),
),
);
}
}

View File

@@ -1,8 +1,9 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/settings_provider.dart';
import 'profile_setup_screen.dart';
import 'debug_notifications_screen.dart';
import 'simple_sync_settings_screen.dart';
class SettingsScreen extends StatelessWidget {
@@ -19,22 +20,25 @@ class SettingsScreen extends StatelessWidget {
return ListView(
padding: const EdgeInsets.all(16.0),
children: [
Card(
child: ListTile(
leading: const Icon(Icons.person),
title: const Text('Profile'),
subtitle: Text('Date of Birth: ${settingsProvider.dateOfBirth != null ? '${settingsProvider.dateOfBirth!.toLocal()}'.split(' ')[0] : 'Not set'}, Gender: ${settingsProvider.gender ?? 'Not set'}'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const ProfileSetupScreen(),
),
);
},
// Debug section (only in debug builds)
if (kDebugMode) ...[
Card(
child: ListTile(
leading: const Icon(Icons.bug_report),
title: const Text('Debug Notifications'),
subtitle: const Text('View scheduled notifications and debug log'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const DebugNotificationsScreen(),
),
);
},
),
),
),
const SizedBox(height: 16),
const SizedBox(height: 16),
],
Card(
child: ListTile(
leading: const Icon(Icons.cloud_sync),
@@ -100,7 +104,28 @@ class SettingsScreen extends StatelessWidget {
),
),
const SizedBox(height: 16),
// Reminders settings removed
// Notifications
Card(
child: ListTile(
leading: const Icon(Icons.snooze),
title: const Text('Snooze duration'),
subtitle: const Text('Delay for Snooze action'),
trailing: DropdownButton<int>(
value: settingsProvider.snoozeMinutes,
items: const [
DropdownMenuItem(value: 5, child: Text('5 min')),
DropdownMenuItem(value: 10, child: Text('10 min')),
DropdownMenuItem(value: 15, child: Text('15 min')),
DropdownMenuItem(value: 20, child: Text('20 min')),
],
onChanged: (value) {
if (value != null) {
settingsProvider.setSnoozeMinutes(value);
}
},
),
),
),
const SizedBox(height: 16),
Card(
child: Padding(