mirror of
https://github.com/vleeuwenmenno/supplements.git
synced 2025-09-11 18:29:12 +02:00
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:
@@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -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');
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user