feat: Add settings provider for theme and time range management

- Implemented SettingsProvider to manage user preferences for theme options and time ranges for reminders.
- Added persistent reminder settings with configurable retry intervals and maximum attempts.
- Created UI for settings screen to allow users to customize their preferences.
- Integrated shared_preferences for persistent storage of user settings.

feat: Introduce Ingredient model

- Created Ingredient model to represent nutritional components with properties for id, name, amount, and unit.
- Added methods for serialization and deserialization of Ingredient objects.

feat: Develop Archived Supplements Screen

- Implemented ArchivedSupplementsScreen to display archived supplements with options to unarchive or delete.
- Added UI components for listing archived supplements and handling user interactions.

chore: Update dependencies in pubspec.yaml and pubspec.lock

- Updated shared_preferences dependency to the latest version.
- Removed flutter_datetime_picker_plus dependency and added file dependency.
- Updated Flutter SDK constraint to >=3.27.0.
This commit is contained in:
2025-08-26 17:19:54 +02:00
parent e6181add08
commit 2aec59ec35
18 changed files with 3756 additions and 376 deletions

View File

@@ -2,15 +2,17 @@ import 'package:sqflite/sqflite.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:path/path.dart';
import 'dart:io';
import 'dart:convert';
import '../models/supplement.dart';
import '../models/supplement_intake.dart';
class DatabaseHelper {
static const _databaseName = 'supplements.db';
static const _databaseVersion = 2; // Increment version for schema changes
static const _databaseVersion = 5; // Increment version for notification tracking
static const supplementsTable = 'supplements';
static const intakesTable = 'supplement_intakes';
static const notificationTrackingTable = 'notification_tracking';
DatabaseHelper._privateConstructor();
static final DatabaseHelper instance = DatabaseHelper._privateConstructor();
@@ -50,9 +52,9 @@ class DatabaseHelper {
CREATE TABLE $supplementsTable (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
dosageAmount REAL NOT NULL,
brand TEXT,
ingredients TEXT NOT NULL DEFAULT '[]',
numberOfUnits INTEGER NOT NULL DEFAULT 1,
unit TEXT NOT NULL,
unitType TEXT NOT NULL DEFAULT 'units',
frequencyPerDay INTEGER NOT NULL,
reminderTimes TEXT NOT NULL,
@@ -73,6 +75,20 @@ class DatabaseHelper {
FOREIGN KEY (supplementId) REFERENCES $supplementsTable (id)
)
''');
await db.execute('''
CREATE TABLE $notificationTrackingTable (
id INTEGER PRIMARY KEY AUTOINCREMENT,
notificationId INTEGER NOT NULL UNIQUE,
supplementId INTEGER NOT NULL,
scheduledTime TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
retryCount INTEGER NOT NULL DEFAULT 0,
lastRetryTime TEXT,
createdAt TEXT NOT NULL,
FOREIGN KEY (supplementId) REFERENCES $supplementsTable (id)
)
''');
}
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
@@ -97,6 +113,7 @@ class DatabaseHelper {
CREATE TABLE ${supplementsTable}_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
brand TEXT,
dosageAmount REAL NOT NULL,
numberOfUnits INTEGER NOT NULL DEFAULT 1,
unit TEXT NOT NULL,
@@ -112,8 +129,8 @@ class DatabaseHelper {
// Copy data to new table
await db.execute('''
INSERT INTO ${supplementsTable}_new
(id, name, dosageAmount, numberOfUnits, unit, unitType, frequencyPerDay, reminderTimes, notes, createdAt, isActive)
SELECT id, name, dosageAmount, numberOfUnits, unit, unitType, frequencyPerDay, reminderTimes, notes, createdAt, isActive
(id, name, brand, dosageAmount, numberOfUnits, unit, unitType, frequencyPerDay, reminderTimes, notes, createdAt, isActive)
SELECT id, name, NULL as brand, dosageAmount, numberOfUnits, unit, unitType, frequencyPerDay, reminderTimes, notes, createdAt, isActive
FROM $supplementsTable
''');
@@ -121,6 +138,86 @@ class DatabaseHelper {
await db.execute('DROP TABLE $supplementsTable');
await db.execute('ALTER TABLE ${supplementsTable}_new RENAME TO $supplementsTable');
}
if (oldVersion < 3) {
// Add brand column for version 3
await db.execute('ALTER TABLE $supplementsTable ADD COLUMN brand TEXT');
}
if (oldVersion < 4) {
// Complete migration to new ingredient-based schema
// Add ingredients column and migrate old data
await db.execute('ALTER TABLE $supplementsTable ADD COLUMN ingredients TEXT DEFAULT "[]"');
// Migrate existing supplements to use ingredients format
final supplements = await db.query(supplementsTable);
for (final supplement in supplements) {
final dosageAmount = supplement['dosageAmount'] as double?;
final unit = supplement['unit'] as String?;
final name = supplement['name'] as String;
if (dosageAmount != null && unit != null && dosageAmount > 0) {
// Create a single ingredient from the old dosage data
final ingredient = {
'name': name,
'amount': dosageAmount,
'unit': unit,
};
final ingredientsJson = jsonEncode([ingredient]);
await db.update(
supplementsTable,
{'ingredients': ingredientsJson},
where: 'id = ?',
whereArgs: [supplement['id']],
);
}
}
// Remove old columns
await db.execute('''
CREATE TABLE ${supplementsTable}_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
brand TEXT,
ingredients TEXT NOT NULL DEFAULT '[]',
numberOfUnits INTEGER NOT NULL DEFAULT 1,
unitType TEXT NOT NULL DEFAULT 'units',
frequencyPerDay INTEGER NOT NULL,
reminderTimes TEXT NOT NULL,
notes TEXT,
createdAt TEXT NOT NULL,
isActive INTEGER NOT NULL DEFAULT 1
)
''');
await db.execute('''
INSERT INTO ${supplementsTable}_new
(id, name, brand, ingredients, numberOfUnits, unitType, frequencyPerDay, reminderTimes, notes, createdAt, isActive)
SELECT id, name, brand, ingredients, numberOfUnits, unitType, frequencyPerDay, reminderTimes, notes, createdAt, isActive
FROM $supplementsTable
''');
await db.execute('DROP TABLE $supplementsTable');
await db.execute('ALTER TABLE ${supplementsTable}_new RENAME TO $supplementsTable');
}
if (oldVersion < 5) {
// Add notification tracking table
await db.execute('''
CREATE TABLE $notificationTrackingTable (
id INTEGER PRIMARY KEY AUTOINCREMENT,
notificationId INTEGER NOT NULL UNIQUE,
supplementId INTEGER NOT NULL,
scheduledTime TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
retryCount INTEGER NOT NULL DEFAULT 0,
lastRetryTime TEXT,
createdAt TEXT NOT NULL,
FOREIGN KEY (supplementId) REFERENCES $supplementsTable (id)
)
''');
}
}
// Supplement CRUD operations
@@ -140,6 +237,37 @@ class DatabaseHelper {
return List.generate(maps.length, (i) => Supplement.fromMap(maps[i]));
}
Future<List<Supplement>> getArchivedSupplements() async {
Database db = await database;
List<Map<String, dynamic>> maps = await db.query(
supplementsTable,
where: 'isActive = ?',
whereArgs: [0],
orderBy: 'name ASC',
);
return List.generate(maps.length, (i) => Supplement.fromMap(maps[i]));
}
Future<void> archiveSupplement(int id) async {
Database db = await database;
await db.update(
supplementsTable,
{'isActive': 0},
where: 'id = ?',
whereArgs: [id],
);
}
Future<void> unarchiveSupplement(int id) async {
Database db = await database;
await db.update(
supplementsTable,
{'isActive': 1},
where: 'id = ?',
whereArgs: [id],
);
}
Future<Supplement?> getSupplement(int id) async {
Database db = await database;
List<Map<String, dynamic>> maps = await db.query(
@@ -213,7 +341,10 @@ class DatabaseHelper {
String endDate = DateTime(date.year, date.month, date.day, 23, 59, 59).toIso8601String();
List<Map<String, dynamic>> result = await db.rawQuery('''
SELECT i.*, s.name as supplementName, s.unit as supplementUnit, s.unitType as supplementUnitType
SELECT i.*,
i.supplementId as supplement_id,
s.name as supplementName,
s.unitType as supplementUnitType
FROM $intakesTable i
JOIN $supplementsTable s ON i.supplementId = s.id
WHERE i.takenAt >= ? AND i.takenAt <= ?
@@ -229,7 +360,10 @@ class DatabaseHelper {
String endDate = DateTime(year, month + 1, 0, 23, 59, 59).toIso8601String();
List<Map<String, dynamic>> result = await db.rawQuery('''
SELECT i.*, s.name as supplementName, s.unit as supplementUnit, s.unitType as supplementUnitType
SELECT i.*,
i.supplementId as supplement_id,
s.name as supplementName,
s.unitType as supplementUnitType
FROM $intakesTable i
JOIN $supplementsTable s ON i.supplementId = s.id
WHERE i.takenAt >= ? AND i.takenAt <= ?
@@ -247,4 +381,90 @@ class DatabaseHelper {
whereArgs: [id],
);
}
// Notification tracking methods
Future<int> trackNotification({
required int notificationId,
required int supplementId,
required DateTime scheduledTime,
}) async {
Database db = await database;
// Use INSERT OR REPLACE to handle both new and existing notifications
await db.rawInsert('''
INSERT OR REPLACE INTO $notificationTrackingTable
(notificationId, supplementId, scheduledTime, status, retryCount, lastRetryTime, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?)
''', [
notificationId,
supplementId,
scheduledTime.toIso8601String(),
'pending',
0,
null,
DateTime.now().toIso8601String(),
]);
return notificationId;
}
Future<void> markNotificationTaken(int notificationId) async {
Database db = await database;
await db.update(
notificationTrackingTable,
{'status': 'taken'},
where: 'notificationId = ?',
whereArgs: [notificationId],
);
}
Future<void> incrementRetryCount(int notificationId) async {
Database db = await database;
await db.rawUpdate('''
UPDATE $notificationTrackingTable
SET retryCount = retryCount + 1,
lastRetryTime = ?,
status = 'retrying'
WHERE notificationId = ?
''', [DateTime.now().toIso8601String(), notificationId]);
}
Future<List<Map<String, dynamic>>> getPendingNotifications() async {
Database db = await database;
return await db.query(
notificationTrackingTable,
where: 'status IN (?, ?)',
whereArgs: ['pending', 'retrying'],
);
}
Future<void> markNotificationExpired(int notificationId) async {
Database db = await database;
await db.update(
notificationTrackingTable,
{'status': 'expired'},
where: 'notificationId = ?',
whereArgs: [notificationId],
);
}
Future<void> cleanupOldNotificationTracking() async {
Database db = await database;
// Remove tracking records older than 7 days
final cutoffDate = DateTime.now().subtract(const Duration(days: 7)).toIso8601String();
await db.delete(
notificationTrackingTable,
where: 'createdAt < ?',
whereArgs: [cutoffDate],
);
}
Future<void> clearNotificationTracking(int supplementId) async {
Database db = await database;
await db.delete(
notificationTrackingTable,
where: 'supplementId = ?',
whereArgs: [supplementId],
);
}
}

View File

@@ -2,6 +2,24 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/timezone.dart' as tz;
import 'package:timezone/data/latest.dart' as tz;
import '../models/supplement.dart';
import 'database_helper.dart';
// Top-level function to handle notification responses when app is running
@pragma('vm:entry-point')
void notificationTapBackground(NotificationResponse notificationResponse) {
print('📱 === BACKGROUND NOTIFICATION RESPONSE ===');
print('📱 Action ID: ${notificationResponse.actionId}');
print('📱 Payload: ${notificationResponse.payload}');
print('📱 Notification ID: ${notificationResponse.id}');
print('📱 ==========================================');
// For now, just log the action. The main app handler will process it.
if (notificationResponse.actionId == 'take_supplement') {
print('📱 BACKGROUND: Take action detected');
} else if (notificationResponse.actionId == 'snooze_10') {
print('📱 BACKGROUND: Snooze action detected');
}
}
class NotificationService {
static final NotificationService _instance = NotificationService._internal();
@@ -9,15 +27,81 @@ class NotificationService {
NotificationService._internal();
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin();
bool _isInitialized = false;
static bool _engineInitialized = false;
bool _permissionsRequested = false;
// Callback for handling supplement intake from notifications
Function(int supplementId, String supplementName, double units, String unitType)? _onTakeSupplementCallback;
// Set callback for handling supplement intake from notifications
void setTakeSupplementCallback(Function(int supplementId, String supplementName, double units, String unitType) callback) {
_onTakeSupplementCallback = callback;
}
Future<void> initialize() async {
tz.initializeTimeZones();
print('📱 Initializing NotificationService...');
if (_isInitialized) {
print('📱 Already initialized');
return;
}
try {
print('📱 Initializing timezones...');
print('📱 Engine initialized flag: $_engineInitialized');
if (!_engineInitialized) {
tz.initializeTimeZones();
_engineInitialized = true;
print('📱 Timezones initialized successfully');
} else {
print('📱 Timezones already initialized, skipping');
}
} catch (e) {
print('📱 Warning: Timezone initialization issue (may already be initialized): $e');
_engineInitialized = true; // Mark as initialized to prevent retry
}
// Try to detect and set the local timezone more reliably
try {
// First try using the system timezone name
final String timeZoneName = DateTime.now().timeZoneName;
print('📱 System timezone name: $timeZoneName');
tz.Location? location;
// Try common timezone mappings for your region
if (timeZoneName.contains('CET') || timeZoneName.contains('CEST')) {
location = tz.getLocation('Europe/Amsterdam'); // Netherlands
} else if (timeZoneName.contains('UTC') || timeZoneName.contains('GMT')) {
location = tz.getLocation('UTC');
} else {
// Fallback: try to use the timezone name directly
try {
location = tz.getLocation(timeZoneName);
} catch (e) {
print('📱 Could not find timezone $timeZoneName, using Europe/Amsterdam as default');
location = tz.getLocation('Europe/Amsterdam');
}
}
tz.setLocalLocation(location);
print('📱 Timezone set to: ${location.name}');
} catch (e) {
print('📱 Error setting timezone: $e, using default');
// Fallback to a reasonable default for Netherlands
tz.setLocalLocation(tz.getLocation('Europe/Amsterdam'));
}
print('📱 Current local time: ${tz.TZDateTime.now(tz.local)}');
print('📱 Current system time: ${DateTime.now()}');
const AndroidInitializationSettings androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const DarwinInitializationSettings iosSettings = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
requestAlertPermission: false, // We'll request these separately
requestBadgePermission: false,
requestSoundPermission: false,
);
const LinuxInitializationSettings linuxSettings = LinuxInitializationSettings(
defaultActionName: 'Open notification',
@@ -29,28 +113,347 @@ class NotificationService {
linux: linuxSettings,
);
await _notifications.initialize(initSettings);
print('📱 Initializing flutter_local_notifications...');
await _notifications.initialize(
initSettings,
onDidReceiveNotificationResponse: _onNotificationResponse,
onDidReceiveBackgroundNotificationResponse: notificationTapBackground,
);
// Test if notification response callback is working
print('📱 Callback function is set and ready');
_isInitialized = true;
print('📱 NotificationService initialization complete');
}
// Handle notification responses (when user taps on notification or action)
void _onNotificationResponse(NotificationResponse response) {
print('📱 === NOTIFICATION RESPONSE ===');
print('📱 Action ID: ${response.actionId}');
print('📱 Payload: ${response.payload}');
print('📱 Notification ID: ${response.id}');
print('📱 Input: ${response.input}');
print('📱 ===============================');
if (response.actionId == 'take_supplement') {
print('📱 Processing TAKE action...');
_handleTakeAction(response.payload, response.id);
} else if (response.actionId == 'snooze_10') {
print('📱 Processing SNOOZE action...');
_handleSnoozeAction(response.payload, 10, response.id);
} else {
print('📱 Default notification tap (no specific action)');
// Default tap (no actionId) opens the app normally
}
}
void _handleTakeAction(String? payload, int? notificationId) {
print('📱 === HANDLING TAKE ACTION ===');
print('📱 Payload received: $payload');
if (payload != null) {
try {
// Parse the payload to get supplement info
final parts = payload.split('|');
print('📱 Payload parts: $parts (length: ${parts.length})');
if (parts.length >= 4) {
final supplementId = int.parse(parts[0]);
final supplementName = parts[1];
final units = double.parse(parts[2]);
final unitType = parts[3];
print('📱 Parsed data:');
print('📱 - ID: $supplementId');
print('📱 - Name: $supplementName');
print('📱 - Units: $units');
print('📱 - Type: $unitType');
// Call the callback to record the intake
if (_onTakeSupplementCallback != null) {
print('📱 Calling supplement callback...');
_onTakeSupplementCallback!(supplementId, supplementName, units, unitType);
print('📱 Callback completed');
} else {
print('📱 ERROR: No callback registered!');
}
// Mark notification as taken in database (this will cancel any pending retries)
if (notificationId != null) {
print('📱 Marking notification $notificationId as taken');
DatabaseHelper.instance.markNotificationTaken(notificationId);
// Cancel any pending retry notifications for this notification
_cancelRetryNotifications(notificationId);
}
// Show a confirmation notification
print('📱 Showing confirmation notification...');
showInstantNotification(
'Supplement Taken!',
'$supplementName has been recorded at ${DateTime.now().hour.toString().padLeft(2, '0')}:${DateTime.now().minute.toString().padLeft(2, '0')}',
);
} else {
print('📱 ERROR: Invalid payload format - not enough parts');
}
} catch (e) {
print('📱 ERROR in _handleTakeAction: $e');
}
} else {
print('📱 ERROR: Payload is null');
}
print('📱 === TAKE ACTION COMPLETE ===');
}
void _cancelRetryNotifications(int notificationId) {
// Retry notifications use ID range starting from 200000
for (int i = 0; i < 10; i++) { // Cancel up to 10 potential retries
int retryId = 200000 + (notificationId * 10) + i;
_notifications.cancel(retryId);
print('📱 Cancelled retry notification ID: $retryId');
}
}
void _handleSnoozeAction(String? payload, int minutes, int? notificationId) {
print('📱 === HANDLING SNOOZE ACTION ===');
print('📱 Payload: $payload, Minutes: $minutes');
if (payload != null) {
try {
final parts = payload.split('|');
if (parts.length >= 2) {
final supplementId = int.parse(parts[0]);
final supplementName = parts[1];
print('📱 Snoozing supplement for $minutes minutes: $supplementName');
// Mark notification as snoozed in database (increment retry count)
if (notificationId != null) {
print('📱 Incrementing retry count for notification $notificationId');
DatabaseHelper.instance.incrementRetryCount(notificationId);
}
// Schedule a new notification for the snooze time
final snoozeTime = tz.TZDateTime.now(tz.local).add(Duration(minutes: minutes));
print('📱 Snooze time: $snoozeTime');
_notifications.zonedSchedule(
supplementId * 1000 + minutes, // Unique ID for snooze notifications
'Reminder: $supplementName',
'Snoozed reminder - Take your $supplementName now',
snoozeTime,
NotificationDetails(
android: AndroidNotificationDetails(
'supplement_reminders',
'Supplement Reminders',
channelDescription: 'Notifications for supplement intake reminders',
importance: Importance.high,
priority: Priority.high,
actions: [
AndroidNotificationAction(
'take_supplement',
'Take',
),
AndroidNotificationAction(
'snooze_10',
'Snooze 10min',
),
],
),
iOS: const DarwinNotificationDetails(),
),
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
payload: payload,
);
showInstantNotification(
'Reminder Snoozed',
'$supplementName reminder snoozed for $minutes minutes',
);
print('📱 Snooze scheduled successfully');
}
} catch (e) {
print('📱 Error handling snooze action: $e');
}
}
print('📱 === SNOOZE ACTION COMPLETE ===');
}
/// Check for persistent reminders from app context with settings
Future<void> checkPersistentReminders(
bool persistentReminders,
int reminderRetryInterval,
int maxRetryAttempts,
) async {
await schedulePersistentReminders(
persistentReminders: persistentReminders,
reminderRetryInterval: reminderRetryInterval,
maxRetryAttempts: maxRetryAttempts,
);
}
/// Check for pending notifications that need retry and schedule them
Future<void> schedulePersistentReminders({
required bool persistentReminders,
required int reminderRetryInterval,
required int maxRetryAttempts,
}) async {
print('📱 Checking for pending notifications to retry...');
try {
if (!persistentReminders) {
print('📱 Persistent reminders disabled');
return;
}
print('📱 Retry settings: interval=$reminderRetryInterval min, max=$maxRetryAttempts attempts');
// Get all pending notifications from database
final pendingNotifications = await DatabaseHelper.instance.getPendingNotifications();
print('📱 Found ${pendingNotifications.length} pending notifications');
final now = DateTime.now();
for (final notification in pendingNotifications) {
final scheduledTime = DateTime.parse(notification['scheduledTime']);
final retryCount = notification['retryCount'] as int;
final lastRetryTime = notification['lastRetryTime'] != null
? DateTime.parse(notification['lastRetryTime'])
: null;
// Check if notification is overdue
final timeSinceScheduled = now.difference(scheduledTime).inMinutes;
final shouldRetry = timeSinceScheduled >= reminderRetryInterval;
// Check if we haven't exceeded max retry attempts
if (retryCount >= maxRetryAttempts) {
print('📱 Notification ${notification['notificationId']} exceeded max attempts ($maxRetryAttempts)');
continue;
}
// Check if enough time has passed since last retry
if (lastRetryTime != null) {
final timeSinceLastRetry = now.difference(lastRetryTime).inMinutes;
if (timeSinceLastRetry < reminderRetryInterval) {
print('📱 Notification ${notification['notificationId']} not ready for retry yet');
continue;
}
}
if (shouldRetry) {
await _scheduleRetryNotification(notification, retryCount + 1);
}
}
} catch (e) {
print('📱 Error scheduling persistent reminders: $e');
}
}
Future<void> _scheduleRetryNotification(Map<String, dynamic> notification, int retryAttempt) async {
try {
final notificationId = notification['notificationId'] as int;
final supplementId = notification['supplementId'] as int;
// Generate a unique ID for this retry (200000 + original_id * 10 + retry_attempt)
final retryNotificationId = 200000 + (notificationId * 10) + retryAttempt;
print('📱 Scheduling retry notification $retryNotificationId for supplement $supplementId (attempt $retryAttempt)');
// Get supplement details from database
final supplements = await DatabaseHelper.instance.getAllSupplements();
final supplement = supplements.firstWhere((s) => s.id == supplementId && s.isActive, orElse: () => throw Exception('Supplement not found'));
// Schedule the retry notification immediately
await _notifications.show(
retryNotificationId,
'Reminder: ${supplement.name}',
'Don\'t forget to take your ${supplement.name}! (Retry #$retryAttempt)',
NotificationDetails(
android: AndroidNotificationDetails(
'supplement_reminders',
'Supplement Reminders',
channelDescription: 'Notifications for supplement intake reminders',
importance: Importance.high,
priority: Priority.high,
actions: [
AndroidNotificationAction(
'take_supplement',
'Take',
showsUserInterface: true,
icon: DrawableResourceAndroidBitmap('@drawable/ic_check'),
),
AndroidNotificationAction(
'snooze_10',
'Snooze 10min',
showsUserInterface: true,
icon: DrawableResourceAndroidBitmap('@drawable/ic_snooze'),
),
],
),
iOS: const DarwinNotificationDetails(),
),
payload: '${supplement.id}|${supplement.name}|${supplement.numberOfUnits}|${supplement.unitType}|$notificationId',
);
// Update the retry count in database
await DatabaseHelper.instance.incrementRetryCount(notificationId);
print('📱 Retry notification scheduled successfully');
} catch (e) {
print('📱 Error scheduling retry notification: $e');
}
}
Future<bool> requestPermissions() async {
final androidPlugin = _notifications.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
if (androidPlugin != null) {
await androidPlugin.requestNotificationsPermission();
print('📱 Requesting notification permissions...');
if (_permissionsRequested) {
print('📱 Permissions already requested');
return true;
}
final iosPlugin = _notifications.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>();
if (iosPlugin != null) {
await iosPlugin.requestPermissions(
alert: true,
badge: true,
sound: true,
);
try {
_permissionsRequested = true;
final androidPlugin = _notifications.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
if (androidPlugin != null) {
print('📱 Requesting Android permissions...');
final granted = await androidPlugin.requestNotificationsPermission();
print('📱 Android permissions granted: $granted');
if (granted != true) {
_permissionsRequested = false;
return false;
}
}
final iosPlugin = _notifications.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>();
if (iosPlugin != null) {
print('📱 Requesting iOS permissions...');
final granted = await iosPlugin.requestPermissions(
alert: true,
badge: true,
sound: true,
);
print('📱 iOS permissions granted: $granted');
if (granted != true) {
_permissionsRequested = false;
return false;
}
}
print('📱 All permissions granted successfully');
return true;
} catch (e) {
_permissionsRequested = false;
print('📱 Error requesting permissions: $e');
return false;
}
return true;
}
Future<void> scheduleSupplementReminders(Supplement supplement) async {
print('📱 Scheduling reminders for ${supplement.name}');
print('📱 Reminder times: ${supplement.reminderTimes}');
// Cancel existing notifications for this supplement
await cancelSupplementReminders(supplement.id!);
@@ -61,25 +464,59 @@ class NotificationService {
final minute = int.parse(timeParts[1]);
final notificationId = supplement.id! * 100 + i; // Unique ID for each reminder
final scheduledTime = _nextInstanceOfTime(hour, minute);
print('📱 Scheduling notification ID $notificationId for ${timeStr} -> ${scheduledTime}');
// Track this notification in the database
await DatabaseHelper.instance.trackNotification(
notificationId: notificationId,
supplementId: supplement.id!,
scheduledTime: scheduledTime.toLocal(),
);
await _notifications.zonedSchedule(
notificationId,
'Time for ${supplement.name}',
'Take ${supplement.numberOfUnits} ${supplement.unitType} (${supplement.totalDosagePerIntake} ${supplement.unit})',
_nextInstanceOfTime(hour, minute),
const NotificationDetails(
'Take ${supplement.numberOfUnits} ${supplement.unitType} (${supplement.ingredientsPerUnit})',
scheduledTime,
NotificationDetails(
android: AndroidNotificationDetails(
'supplement_reminders',
'Supplement Reminders',
channelDescription: 'Notifications for supplement intake reminders',
importance: Importance.high,
priority: Priority.high,
actions: [
AndroidNotificationAction(
'take_supplement',
'Take',
icon: DrawableResourceAndroidBitmap('@android:drawable/ic_menu_save'),
showsUserInterface: true, // Changed to true to open app
),
AndroidNotificationAction(
'snooze_10',
'Snooze 10min',
icon: DrawableResourceAndroidBitmap('@android:drawable/ic_menu_recent_history'),
showsUserInterface: true, // Changed to true to open app
),
],
),
iOS: DarwinNotificationDetails(),
iOS: const DarwinNotificationDetails(),
),
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
matchDateTimeComponents: DateTimeComponents.time,
payload: '${supplement.id}|${supplement.name}|${supplement.numberOfUnits}|${supplement.unitType}',
);
print('📱 Successfully scheduled notification ID $notificationId');
}
// Get all pending notifications to verify
final pendingNotifications = await _notifications.pendingNotificationRequests();
print('📱 Total pending notifications: ${pendingNotifications.length}');
for (final notification in pendingNotifications) {
print('📱 Pending: ID=${notification.id}, Title=${notification.title}');
}
}
@@ -89,6 +526,9 @@ class NotificationService {
final notificationId = supplementId * 100 + i;
await _notifications.cancel(notificationId);
}
// Also clean up database tracking records for this supplement
await DatabaseHelper.instance.clearNotificationTracking(supplementId);
}
Future<void> cancelAllReminders() async {
@@ -99,14 +539,22 @@ class NotificationService {
final tz.TZDateTime now = tz.TZDateTime.now(tz.local);
tz.TZDateTime scheduledDate = tz.TZDateTime(tz.local, now.year, now.month, now.day, hour, minute);
print('📱 Current time: $now (${now.timeZoneName})');
print('📱 Target time: ${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}');
print('📱 Initial scheduled date: $scheduledDate (${scheduledDate.timeZoneName})');
if (scheduledDate.isBefore(now)) {
scheduledDate = scheduledDate.add(const Duration(days: 1));
print('📱 Time has passed, scheduling for tomorrow: $scheduledDate (${scheduledDate.timeZoneName})');
} else {
print('📱 Time is in the future, scheduling for today: $scheduledDate (${scheduledDate.timeZoneName})');
}
return scheduledDate;
}
Future<void> showInstantNotification(String title, String body) async {
print('📱 Showing instant notification: $title - $body');
const NotificationDetails notificationDetails = NotificationDetails(
android: AndroidNotificationDetails(
'instant_notifications',
@@ -124,5 +572,108 @@ class NotificationService {
body,
notificationDetails,
);
print('📱 Instant notification sent');
}
// Debug function to test notifications
Future<void> testNotification() async {
print('📱 Testing notification system...');
await showInstantNotification('Test Notification', 'This is a test notification to verify the system is working.');
}
// Debug function to schedule a test notification 1 minute from now
Future<void> testScheduledNotification() async {
print('📱 Testing scheduled notification...');
final now = tz.TZDateTime.now(tz.local);
final testTime = now.add(const Duration(minutes: 1));
print('📱 Scheduling test notification for: $testTime');
await _notifications.zonedSchedule(
99999, // Special ID for test notifications
'Test Scheduled Notification',
'This notification was scheduled 1 minute ago at ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}',
testTime,
const NotificationDetails(
android: AndroidNotificationDetails(
'test_notifications',
'Test Notifications',
channelDescription: 'Test notifications for debugging',
importance: Importance.high,
priority: Priority.high,
),
iOS: DarwinNotificationDetails(),
),
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
);
print('📱 Test notification scheduled successfully');
}
// Debug function to get all pending notifications
Future<List<PendingNotificationRequest>> getPendingNotifications() async {
return await _notifications.pendingNotificationRequests();
}
// Debug function to test notification actions
Future<void> testNotificationWithActions() async {
print('📱 Creating test notification with actions...');
await _notifications.show(
88888, // Special test ID
'Test Action Notification',
'Tap Take or Snooze to test notification actions',
NotificationDetails(
android: AndroidNotificationDetails(
'test_notifications',
'Test Notifications',
channelDescription: 'Test notifications for debugging actions',
importance: Importance.high,
priority: Priority.high,
actions: [
AndroidNotificationAction(
'take_supplement',
'Take',
icon: DrawableResourceAndroidBitmap('@android:drawable/ic_menu_save'),
showsUserInterface: true,
),
AndroidNotificationAction(
'snooze_10',
'Snooze 10min',
icon: DrawableResourceAndroidBitmap('@android:drawable/ic_menu_recent_history'),
showsUserInterface: true,
),
],
),
iOS: const DarwinNotificationDetails(),
),
payload: '999|Test Supplement|1.0|capsule',
);
print('📱 Test notification with actions created');
}
// Debug function to test basic notification tap response
Future<void> testBasicNotification() async {
print('📱 Creating basic test notification...');
await _notifications.show(
77777, // Special test ID for basic notification
'Basic Test Notification',
'Tap this notification to test basic callback',
NotificationDetails(
android: AndroidNotificationDetails(
'test_notifications',
'Test Notifications',
channelDescription: 'Test notifications for debugging',
importance: Importance.high,
priority: Priority.high,
),
iOS: const DarwinNotificationDetails(),
),
payload: 'basic_test',
);
print('📱 Basic test notification created');
}
}