diff --git a/lib/main.dart b/lib/main.dart index 1207c30..1218a11 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,20 +1,37 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; // Import this +import 'package:provider/provider.dart'; import 'package:supplements/logging.dart'; import 'providers/settings_provider.dart'; import 'providers/simple_sync_provider.dart'; import 'providers/supplement_provider.dart'; import 'screens/home_screen.dart'; -import 'screens/profile_setup_screen.dart'; import 'services/notification_router.dart'; import 'services/simple_notification_service.dart'; final GlobalKey navigatorKey = GlobalKey(); +// Top-level function to handle notification responses in the background +@pragma('vm:entry-point') +void notificationTapBackground(NotificationResponse notificationResponse) { + // handle action here + printLog('Background notification action tapped: ${notificationResponse.actionId}'); + NotificationRouter.instance.handleNotificationResponse(notificationResponse); +} + void main() async { WidgetsFlutterBinding.ensureInitialized(); + + // Initialize SimpleNotificationService early + await SimpleNotificationService.instance.initialize( + onDidReceiveBackgroundNotificationResponse: notificationTapBackground, + ); + final settingsProvider = SettingsProvider(); await settingsProvider.initialize(); @@ -48,9 +65,11 @@ class MyApp extends StatelessWidget { final supplementProvider = context.read(); // Initialize notification router with the app's navigator + // This is done here because navigatorKey is only available after MaterialApp is built NotificationRouter.instance.initialize(navigatorKey); // If the app was launched via a notification, route to the proper dialog + // This needs to be called after the router is initialized with the navigatorKey SimpleNotificationService.instance.getLaunchDetails().then((details) { NotificationRouter.instance.handleAppLaunchDetails(details); }); @@ -94,9 +113,7 @@ class MyApp extends StatelessWidget { useMaterial3: true, ), themeMode: settingsProvider.themeMode, - home: (settingsProvider.age == null || settingsProvider.gender == null) - ? const ProfileSetupScreen() - : const HomeScreen(), + home: const HomeScreen(), debugShowCheckedModeBanner: false, ); }, diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index d5424e4..de592b5 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -24,7 +24,8 @@ class SettingsProvider extends ChangeNotifier { int _nightStart = 23; int _nightEnd = 4; - + // Notifications + int _snoozeMinutes = 10; // Auto-sync settings bool _autoSyncEnabled = false; @@ -32,19 +33,6 @@ class SettingsProvider extends ChangeNotifier { ThemeOption get themeOption => _themeOption; - // Profile getters - DateTime? get dateOfBirth => _dateOfBirth; - String? get gender => _gender; - int? get age { - if (_dateOfBirth == null) return null; - final now = DateTime.now(); - int years = now.year - _dateOfBirth!.year; - final hasHadBirthday = (now.month > _dateOfBirth!.month) || - (now.month == _dateOfBirth!.month && now.day >= _dateOfBirth!.day); - if (!hasHadBirthday) years--; - return years; - } - // Time range getters int get morningStart => _morningStart; int get morningEnd => _morningEnd; @@ -55,7 +43,8 @@ class SettingsProvider extends ChangeNotifier { int get nightStart => _nightStart; int get nightEnd => _nightEnd; - + // Notifications + int get snoozeMinutes => _snoozeMinutes; // Auto-sync getters bool get autoSyncEnabled => _autoSyncEnabled; @@ -104,7 +93,8 @@ class SettingsProvider extends ChangeNotifier { _nightStart = prefs.getInt('night_start') ?? 23; _nightEnd = prefs.getInt('night_end') ?? 4; - + // Load snooze setting + _snoozeMinutes = prefs.getInt('snooze_minutes') ?? 10; // Load auto-sync settings _autoSyncEnabled = prefs.getBool('auto_sync_enabled') ?? false; @@ -269,7 +259,18 @@ class SettingsProvider extends ChangeNotifier { } } + // Notifications setters + Future setSnoozeMinutes(int minutes) async { + const allowed = [5, 10, 15, 20]; + if (!allowed.contains(minutes)) { + throw ArgumentError('Snooze minutes must be one of ${allowed.join(", ")}'); + } + _snoozeMinutes = minutes; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('snooze_minutes', minutes); + } // Auto-sync setters Future setAutoSyncEnabled(bool enabled) async { diff --git a/lib/screens/debug_notifications_screen.dart b/lib/screens/debug_notifications_screen.dart new file mode 100644 index 0000000..96941a0 --- /dev/null +++ b/lib/screens/debug_notifications_screen.dart @@ -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 createState() => _DebugNotificationsScreenState(); +} + +class _DebugNotificationsScreenState extends State { + bool _loading = true; + List _pending = const []; + List _logEntries = const []; + final Map _supplementNameCache = {}; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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 _resolveSupplementNames( + List pending, + List logs, + ) async { + final ctx = context; + if (!mounted) return; + final provider = Provider.of(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 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 _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 _cancelAll() async { + await SimpleNotificationService.instance.cancelAll(); + await _load(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Canceled all notifications')), + ); + } + + Future _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 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 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), + ), + ], + ), + ); + } +} diff --git a/lib/screens/profile_setup_screen.dart b/lib/screens/profile_setup_screen.dart deleted file mode 100644 index 12fb256..0000000 --- a/lib/screens/profile_setup_screen.dart +++ /dev/null @@ -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 createState() => _ProfileSetupScreenState(); -} - -class _ProfileSetupScreenState extends State { - final _formKey = GlobalKey(); - DateTime? _dateOfBirth; - String? _gender; - - final List _genders = ['Male', 'Female', 'Other', 'Prefer not to say']; - - @override - void initState() { - super.initState(); - final settingsProvider = Provider.of(context, listen: false); - _dateOfBirth = settingsProvider.dateOfBirth; - _gender = settingsProvider.gender; - } - - void _saveProfile() { - if (_formKey.currentState!.validate()) { - _formKey.currentState!.save(); - Provider.of(context, listen: false).setDateOfBirthAndGender(_dateOfBirth!, _gender!); - Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (context) => HomeScreen())); - } - } - - Future _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( - decoration: const InputDecoration( - labelText: 'Gender', - border: OutlineInputBorder(), - ), - value: _gender, - items: _genders.map((String gender) { - return DropdownMenuItem( - 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'), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 55ff76d..be58009 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -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( + 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( diff --git a/lib/services/notification_debug_store.dart b/lib/services/notification_debug_store.dart new file mode 100644 index 0000000..39db4c1 --- /dev/null +++ b/lib/services/notification_debug_store.dart @@ -0,0 +1,92 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +class NotificationLogEntry { + final int id; + final String kind; // 'daily' | 'snooze' + final String type; // 'single' | 'group' + final int whenEpochMs; // exact scheduled time (epoch ms) + final int createdAtEpochMs; // when we created the schedule (epoch ms) + final String title; + final String payload; + final int? singleId; // supplement id for single + final String? timeKey; // HH:mm for group + + const NotificationLogEntry({ + required this.id, + required this.kind, + required this.type, + required this.whenEpochMs, + required this.createdAtEpochMs, + required this.title, + required this.payload, + this.singleId, + this.timeKey, + }); + + Map toJson() => { + 'id': id, + 'kind': kind, + 'type': type, + 'when': whenEpochMs, + 'createdAt': createdAtEpochMs, + 'title': title, + 'payload': payload, + 'singleId': singleId, + 'timeKey': timeKey, + }; + + static NotificationLogEntry fromJson(Map map) { + return NotificationLogEntry( + id: map['id'] is int ? map['id'] as int : int.tryParse('${map['id']}') ?? 0, + kind: map['kind'] ?? 'unknown', + type: map['type'] ?? 'unknown', + whenEpochMs: map['when'] is int ? map['when'] as int : int.tryParse('${map['when']}') ?? 0, + createdAtEpochMs: map['createdAt'] is int ? map['createdAt'] as int : int.tryParse('${map['createdAt']}') ?? 0, + title: map['title'] ?? '', + payload: map['payload'] ?? '', + singleId: map['singleId'], + timeKey: map['timeKey'], + ); + } +} + +class NotificationDebugStore { + NotificationDebugStore._internal(); + static final NotificationDebugStore instance = NotificationDebugStore._internal(); + + static const String _prefsKey = 'notification_log'; + static const int _maxEntries = 200; + + Future> getAll() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_prefsKey); + if (raw == null || raw.isEmpty) return []; + try { + final list = jsonDecode(raw) as List; + return list + .map((e) => NotificationLogEntry.fromJson(e as Map)) + .toList(); + } catch (_) { + return []; + } + } + + Future add(NotificationLogEntry entry) async { + final prefs = await SharedPreferences.getInstance(); + final current = await getAll(); + current.add(entry); + // Cap size + final trimmed = current.length > _maxEntries + ? current.sublist(current.length - _maxEntries) + : current; + final serialized = jsonEncode(trimmed.map((e) => e.toJson()).toList()); + await prefs.setString(_prefsKey, serialized); + } + + Future clear() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_prefsKey); + } +} diff --git a/lib/services/notification_router.dart b/lib/services/notification_router.dart index 50fa9fb..8522baa 100644 --- a/lib/services/notification_router.dart +++ b/lib/services/notification_router.dart @@ -10,6 +10,8 @@ import '../models/supplement.dart'; import '../providers/supplement_provider.dart'; import '../widgets/dialogs/bulk_take_dialog.dart'; import '../widgets/dialogs/take_supplement_dialog.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supplements/services/simple_notification_service.dart'; /// Centralizes routing from notification actions/taps to in-app UI. /// Handles both foreground/background taps and terminated-launch scenarios. @@ -27,7 +29,22 @@ class NotificationRouter { final payloadMap = _decodePayload(response.payload); final actionId = response.actionId; printLog('šŸ”” handleNotificationResponse: actionId=$actionId payload=${response.payload} map=$payloadMap'); + printLog('šŸ”” handleNotificationResponse: Received actionId: $actionId'); + printLog('šŸ”” handleNotificationResponse: Decoded payloadMap: $payloadMap'); + // Handle Snooze actions without surfacing UI + if (actionId == 'snooze_single' || actionId == 'snooze_group') { + try { + final prefs = await SharedPreferences.getInstance(); + final minutes = prefs.getInt('snooze_minutes') ?? 10; + await _scheduleSnoozeFromPayload(payloadMap, Duration(minutes: minutes)); + } catch (e) { + printLog('āš ļø Failed to handle snooze action: $e'); + } + return; + } + + // Default: route to in-app UI for Take actions and normal taps await _routeFromPayload(payloadMap); } @@ -134,14 +151,124 @@ class NotificationRouter { } } + Future _scheduleSnoozeFromPayload(Map? payload, Duration delay) async { + if (payload == null) { + printLog('āš ļø Snooze requested but payload was null'); + return; + } + + // Try to wait for providers to be ready to build rich content. + final ready = await _waitUntilReady(timeout: const Duration(seconds: 5)); + BuildContext? ctx = _navigatorKey?.currentContext; + + SupplementProvider? provider; + if (ready && ctx != null) { + provider = Provider.of(ctx, listen: false); + } + + String title = 'Supplement reminder'; + String body = 'Tap to see details'; + bool isSingle = false; + // Start with a mutable copy of the payload to add meta information + final Map mutablePayload = Map.from(payload); + + final type = mutablePayload['type']; + + if (type == 'single') { + final id = payload['id']; + isSingle = true; + // Ensure the payload for single snooze is correctly formatted + mutablePayload['type'] = 'single'; + mutablePayload['id'] = id; + + if (id is int && provider != null) { + Supplement? s; + try { + s = provider.supplements.firstWhere((el) => el.id == id); + } catch (_) { + s = null; + } + if (s != null) { + title = 'Time for ${s.name}'; + body = + '${s.name} — ${s.numberOfUnits} ${s.unitType} (${s.ingredientsPerUnit})'; + } else { + body = 'Tap to take supplement'; + } + } + } else if (type == 'group') { + final timeKey = mutablePayload['time']; + if (timeKey is String) { + if (provider != null) { + final list = provider.supplements + .where((s) => + s.isActive && s.reminderTimes.contains(timeKey)) + .toList(); + + if (list.length == 1) { + final s = list.first; + isSingle = true; + title = 'Time for ${s.name}'; + body = + '${s.name} — ${s.numberOfUnits} ${s.unitType} (${s.ingredientsPerUnit})'; + // If a group becomes a single, update the payload type + mutablePayload['type'] = 'single'; + mutablePayload['id'] = s.id; + mutablePayload.remove('time'); // Remove time key for single + } else if (list.isNotEmpty) { + isSingle = false; + title = 'Time for ${list.length} supplements'; + final lines = list + .map((s) => + '${s.name} — ${s.numberOfUnits} ${s.unitType} (${s.ingredientsPerUnit})') + .toList(); + body = lines.join('\n'); + // Ensure payload type is group + mutablePayload['type'] = 'group'; + mutablePayload['time'] = timeKey; + } else { + // Fallback generic group + isSingle = false; + title = 'Supplement reminder'; + body = 'Tap to see details'; + mutablePayload['type'] = 'group'; + mutablePayload['time'] = timeKey; + } + } else { + // Provider not ready; schedule generic group payload + isSingle = false; + mutablePayload['type'] = 'group'; + mutablePayload['time'] = timeKey; + } + } + } + + // Ensure the payload always has the correct type and ID/time for logging + // and re-scheduling. The SimpleNotificationService will add the 'meta' field. + final payloadStr = jsonEncode(mutablePayload); + + await SimpleNotificationService.instance.scheduleOneOffReminder( + title: title, + body: body, + payload: payloadStr, + isSingle: isSingle, + delay: delay, + ); + } + Future _waitUntilReady({required Duration timeout}) async { final start = DateTime.now(); while (DateTime.now().difference(start) < timeout) { - final ctx = _navigatorKey!.currentContext; + final key = _navigatorKey; + final ctx = key?.currentContext; if (ctx != null) { - final provider = Provider.of(ctx, listen: false); - if (!provider.isLoading) { - return true; + try { + final provider = Provider.of(ctx, listen: false); + if (!provider.isLoading) { + return true; + } + } catch (_) { + // Provider not available yet } } await Future.delayed(const Duration(milliseconds: 100)); diff --git a/lib/services/simple_notification_service.dart b/lib/services/simple_notification_service.dart index da7b2f0..dc00a85 100644 --- a/lib/services/simple_notification_service.dart +++ b/lib/services/simple_notification_service.dart @@ -4,6 +4,7 @@ import 'package:timezone/data/latest.dart' as tzdata; import 'package:timezone/timezone.dart' as tz; import 'dart:convert'; import 'package:supplements/services/notification_router.dart'; +import 'package:supplements/services/notification_debug_store.dart'; import '../models/supplement.dart'; @@ -33,7 +34,9 @@ class SimpleNotificationService { /// /// Note: This does not request runtime permissions. Call [requestPermissions] /// to prompt the user for notification permissions. - Future initialize() async { + Future initialize({ + DidReceiveBackgroundNotificationResponseCallback? onDidReceiveBackgroundNotificationResponse, + }) async { if (_initialized) return; // Initialize timezone database and set a sane default. @@ -52,12 +55,14 @@ class SimpleNotificationService { 'single', actions: [ DarwinNotificationAction.plain('take_single', 'Take'), + DarwinNotificationAction.plain('snooze_single', 'Snooze'), ], ), DarwinNotificationCategory( 'group', actions: [ DarwinNotificationAction.plain('take_group', 'Take All'), + DarwinNotificationAction.plain('snooze_group', 'Snooze'), ], ), ], @@ -77,6 +82,7 @@ class SimpleNotificationService { onDidReceiveNotificationResponse: (response) { NotificationRouter.instance.handleNotificationResponse(response); }, + onDidReceiveBackgroundNotificationResponse: onDidReceiveBackgroundNotificationResponse, ); _initialized = true; @@ -190,9 +196,12 @@ class SimpleNotificationService { // Use BigTextStyle/InboxStyle for Android to show multiple lines final bool isSingle = count == 1; - final String payloadStr = isSingle - ? jsonEncode({"type": "single", "id": items.first.id}) - : jsonEncode({"type": "group", "time": timeKey}); + // Tag payload with origin meta for debug/inspection + final Map payloadMap = isSingle + ? {"type": "single", "id": items.first.id} + : {"type": "group", "time": timeKey}; + payloadMap["meta"] = {"kind": "daily"}; + final String payloadStr = jsonEncode(payloadMap); final androidDetails = AndroidNotificationDetails( _channelDailyId, @@ -220,6 +229,20 @@ class SimpleNotificationService { showsUserInterface: true, cancelNotification: true, ), + if (isSingle) + AndroidNotificationAction( + 'snooze_single', + 'Snooze', + showsUserInterface: false, + // Removed cancelNotification: true for debugging + ) + else + AndroidNotificationAction( + 'snooze_group', + 'Snooze', + showsUserInterface: false, + // Removed cancelNotification: true for debugging + ), ], ); @@ -241,6 +264,22 @@ class SimpleNotificationService { payload: payloadStr, ); + // Log to debug store + final createdAtMs = DateTime.now().millisecondsSinceEpoch; + await NotificationDebugStore.instance.add( + NotificationLogEntry( + id: id, + kind: 'daily', + type: isSingle ? 'single' : 'group', + whenEpochMs: when.millisecondsSinceEpoch, + createdAtEpochMs: createdAtMs, + title: title, + payload: payloadStr, + singleId: isSingle ? items.first.id as int? : null, + timeKey: isSingle ? null : timeKey, + ), + ); + printLog('āœ… Scheduled group $timeKey with ID $id'); } @@ -288,34 +327,204 @@ class SimpleNotificationService { await _plugin.cancelAll(); } + /// Cancel a specific notification by ID. + Future cancelById(int id) async { + if (!_initialized) { + await initialize(); + } + await _plugin.cancel(id); + } + /// Show an immediate notification. Useful for quick diagnostics. Future showInstant({ required String title, required String body, String? payload, + bool includeSnoozeActions = false, // New parameter + bool isSingle = true, // New parameter, defaults to single for instant }) async { if (!_initialized) { await initialize(); } + final androidDetails = AndroidNotificationDetails( + 'instant_notifications', + 'Instant Notifications', + channelDescription: 'One-off or immediate notifications', + importance: Importance.high, + priority: Priority.high, + actions: includeSnoozeActions + ? [ + if (isSingle) + AndroidNotificationAction( + 'take_single', + 'Take', + showsUserInterface: true, + cancelNotification: true, + ) + else + AndroidNotificationAction( + 'take_group', + 'Take All', + showsUserInterface: true, + cancelNotification: true, + ), + if (isSingle) + AndroidNotificationAction( + 'snooze_single', + 'Snooze', + showsUserInterface: false, + cancelNotification: true, + ) + else + AndroidNotificationAction( + 'snooze_group', + 'Snooze', + showsUserInterface: false, + cancelNotification: true, + ), + ] + : [], // No actions by default + ); + + final iosDetails = DarwinNotificationDetails( + categoryIdentifier: includeSnoozeActions + ? (isSingle ? 'single' : 'group') + : null, // Use category for actions + ); + await _plugin.show( DateTime.now().millisecondsSinceEpoch ~/ 1000, title, body, - const NotificationDetails( - android: AndroidNotificationDetails( - 'instant_notifications', - 'Instant Notifications', - channelDescription: 'One-off or immediate notifications', - importance: Importance.high, - priority: Priority.high, - ), - iOS: DarwinNotificationDetails(), + NotificationDetails( + android: androidDetails, + iOS: iosDetails, ), payload: payload, ); } + /// Schedule a one-off (non-repeating) reminder, typically used for Snooze. + Future scheduleOneOffReminder({ + required String title, + required String body, + required String payload, + required bool isSingle, + required Duration delay, + }) async { + if (!_initialized) { + await initialize(); + } + + final when = tz.TZDateTime.now(tz.local).add(delay); + final id = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + final androidDetails = AndroidNotificationDetails( + _channelDailyId, + _channelDailyName, + channelDescription: _channelDailyDescription, + importance: Importance.high, + priority: Priority.high, + styleInformation: BigTextStyleInformation( + body, + contentTitle: title, + htmlFormatContentTitle: false, + ), + actions: [ + if (isSingle) + AndroidNotificationAction( + 'take_single', + 'Take', + showsUserInterface: true, + cancelNotification: true, + ) + else + AndroidNotificationAction( + 'take_group', + 'Take All', + showsUserInterface: true, + cancelNotification: true, + ), + if (isSingle) + AndroidNotificationAction( + 'snooze_single', + 'Snooze', + showsUserInterface: false, + // Removed cancelNotification: true for debugging + ) + else + AndroidNotificationAction( + 'snooze_group', + 'Snooze', + showsUserInterface: false, + // Removed cancelNotification: true for debugging + ), + ], + ); + + final iosDetails = DarwinNotificationDetails( + categoryIdentifier: isSingle ? 'single' : 'group', + ); + + // Enrich payload with meta for snooze; also capture linkage for logging + Map? pmap; + try { + pmap = jsonDecode(payload) as Map; + } catch (_) { + pmap = null; + } + final createdAtMs = DateTime.now().millisecondsSinceEpoch; + String payloadFinal = payload; + int? logSingleId; + String? logTimeKey; + if (pmap != null) { + final meta = { + 'kind': 'snooze', + 'createdAt': createdAtMs, + 'delayMin': delay.inMinutes, + }; + pmap['meta'] = meta; + if (pmap['type'] == 'single') { + final v = pmap['id']; + logSingleId = v is int ? v : null; + } else if (pmap['type'] == 'group') { + logTimeKey = pmap['time'] as String?; + } + payloadFinal = jsonEncode(pmap); + } + + await _plugin.zonedSchedule( + id, + title, + isSingle ? body : 'Tap to see details', + when, + NotificationDetails( + android: androidDetails, + iOS: iosDetails, + ), + androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, + payload: payloadFinal, + ); + + // Log to debug store + await NotificationDebugStore.instance.add( + NotificationLogEntry( + id: id, + kind: 'snooze', + type: isSingle ? 'single' : 'group', + whenEpochMs: when.millisecondsSinceEpoch, + createdAtEpochMs: createdAtMs, + title: title, + payload: payloadFinal, + singleId: logSingleId, + timeKey: logTimeKey, + ), + ); + + printLog('ā° Scheduled one-off reminder (id=$id) at $when, isSingle=$isSingle'); + } + Future getLaunchDetails() async { if (!_initialized) { await initialize();