mirror of
https://github.com/vleeuwenmenno/supplements.git
synced 2025-09-11 18:29:12 +02:00
notification overhaul
This commit is contained in:
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:supplements/logging.dart';
|
||||
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../providers/simple_sync_provider.dart';
|
||||
@@ -69,7 +70,7 @@ class AutoSyncService {
|
||||
// Check if auto-sync is enabled
|
||||
if (!_settingsProvider.autoSyncEnabled) {
|
||||
if (kDebugMode) {
|
||||
print('AutoSyncService: Auto-sync is disabled, skipping trigger');
|
||||
printLog('AutoSyncService: Auto-sync is disabled, skipping trigger');
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -77,7 +78,7 @@ class AutoSyncService {
|
||||
// Check if auto-sync was disabled due to persistent errors
|
||||
if (_autoDisabledDueToErrors) {
|
||||
if (kDebugMode) {
|
||||
print('AutoSyncService: Auto-sync disabled due to persistent errors, skipping trigger');
|
||||
printLog('AutoSyncService: Auto-sync disabled due to persistent errors, skipping trigger');
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -90,7 +91,7 @@ class AutoSyncService {
|
||||
timestamp: DateTime.now(),
|
||||
));
|
||||
if (kDebugMode) {
|
||||
print('AutoSyncService: Sync not configured, skipping auto-sync');
|
||||
printLog('AutoSyncService: Sync not configured, skipping auto-sync');
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -99,7 +100,7 @@ class AutoSyncService {
|
||||
if (_syncInProgress || _syncProvider.isSyncing) {
|
||||
_hasPendingSync = true;
|
||||
if (kDebugMode) {
|
||||
print('AutoSyncService: Sync in progress, marking pending sync');
|
||||
printLog('AutoSyncService: Sync in progress, marking pending sync');
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -111,7 +112,7 @@ class AutoSyncService {
|
||||
final backoffDelay = _calculateBackoffDelay();
|
||||
if (backoffDelay > 0) {
|
||||
if (kDebugMode) {
|
||||
print('AutoSyncService: Applying backoff delay of ${backoffDelay}s due to recent failures');
|
||||
printLog('AutoSyncService: Applying backoff delay of ${backoffDelay}s due to recent failures');
|
||||
}
|
||||
_debounceTimer = Timer(Duration(seconds: backoffDelay), () {
|
||||
_executePendingSync();
|
||||
@@ -126,7 +127,7 @@ class AutoSyncService {
|
||||
});
|
||||
|
||||
if (kDebugMode) {
|
||||
print('AutoSyncService: Auto-sync scheduled in ${debounceSeconds}s');
|
||||
printLog('AutoSyncService: Auto-sync scheduled in ${debounceSeconds}s');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,14 +136,14 @@ class AutoSyncService {
|
||||
// Double-check conditions before executing
|
||||
if (!_settingsProvider.autoSyncEnabled) {
|
||||
if (kDebugMode) {
|
||||
print('AutoSyncService: Auto-sync disabled during execution, aborting');
|
||||
printLog('AutoSyncService: Auto-sync disabled during execution, aborting');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (_autoDisabledDueToErrors) {
|
||||
if (kDebugMode) {
|
||||
print('AutoSyncService: Auto-sync disabled due to errors during execution, aborting');
|
||||
printLog('AutoSyncService: Auto-sync disabled due to errors during execution, aborting');
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -154,14 +155,14 @@ class AutoSyncService {
|
||||
timestamp: DateTime.now(),
|
||||
));
|
||||
if (kDebugMode) {
|
||||
print('AutoSyncService: Sync not configured during execution, aborting');
|
||||
printLog('AutoSyncService: Sync not configured during execution, aborting');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (_syncInProgress || _syncProvider.isSyncing) {
|
||||
if (kDebugMode) {
|
||||
print('AutoSyncService: Sync already in progress during execution, aborting');
|
||||
printLog('AutoSyncService: Sync already in progress during execution, aborting');
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -171,7 +172,7 @@ class AutoSyncService {
|
||||
|
||||
try {
|
||||
if (kDebugMode) {
|
||||
print('AutoSyncService: Executing auto-sync (attempt ${_consecutiveFailures + 1})');
|
||||
printLog('AutoSyncService: Executing auto-sync (attempt ${_consecutiveFailures + 1})');
|
||||
}
|
||||
|
||||
// Check network connectivity before attempting sync
|
||||
@@ -191,12 +192,12 @@ class AutoSyncService {
|
||||
_autoDisabledDueToErrors = false;
|
||||
|
||||
if (kDebugMode) {
|
||||
print('AutoSyncService: Auto-sync completed successfully');
|
||||
printLog('AutoSyncService: Auto-sync completed successfully');
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('AutoSyncService: Auto-sync failed: $e');
|
||||
printLog('AutoSyncService: Auto-sync failed: $e');
|
||||
}
|
||||
|
||||
// Handle specific error types
|
||||
@@ -208,7 +209,7 @@ class AutoSyncService {
|
||||
// If there was a pending sync request while we were syncing, trigger it
|
||||
if (_hasPendingSync && !_autoDisabledDueToErrors) {
|
||||
if (kDebugMode) {
|
||||
print('AutoSyncService: Processing queued sync request');
|
||||
printLog('AutoSyncService: Processing queued sync request');
|
||||
}
|
||||
_hasPendingSync = false;
|
||||
// Use a small delay to avoid immediate re-triggering
|
||||
@@ -231,14 +232,14 @@ class AutoSyncService {
|
||||
if (_consecutiveFailures >= _autoDisableThreshold) {
|
||||
_autoDisabledDueToErrors = true;
|
||||
if (kDebugMode) {
|
||||
print('AutoSyncService: Auto-sync disabled due to ${_consecutiveFailures} consecutive failures');
|
||||
printLog('AutoSyncService: Auto-sync disabled due to ${_consecutiveFailures} consecutive failures');
|
||||
}
|
||||
|
||||
// For configuration errors, disable immediately
|
||||
if (autoSyncError.type == AutoSyncErrorType.configuration ||
|
||||
autoSyncError.type == AutoSyncErrorType.authentication) {
|
||||
if (kDebugMode) {
|
||||
print('AutoSyncService: Auto-sync disabled due to configuration/authentication error');
|
||||
printLog('AutoSyncService: Auto-sync disabled due to configuration/authentication error');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -328,7 +329,7 @@ class AutoSyncService {
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
print('AutoSyncService: Recorded error: $error');
|
||||
printLog('AutoSyncService: Recorded error: $error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,13 +376,13 @@ class AutoSyncService {
|
||||
_retryTimer?.cancel();
|
||||
_retryTimer = Timer(Duration(seconds: retryDelay), () {
|
||||
if (kDebugMode) {
|
||||
print('AutoSyncService: Retrying auto-sync after backoff delay');
|
||||
printLog('AutoSyncService: Retrying auto-sync after backoff delay');
|
||||
}
|
||||
triggerAutoSync();
|
||||
});
|
||||
|
||||
if (kDebugMode) {
|
||||
print('AutoSyncService: Scheduled retry in ${retryDelay}s');
|
||||
printLog('AutoSyncService: Scheduled retry in ${retryDelay}s');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,7 +393,7 @@ class AutoSyncService {
|
||||
return result.isNotEmpty && result[0].rawAddress.isNotEmpty;
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('AutoSyncService: Network check failed: $e');
|
||||
printLog('AutoSyncService: Network check failed: $e');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -406,7 +407,7 @@ class AutoSyncService {
|
||||
_hasPendingSync = false;
|
||||
|
||||
if (kDebugMode) {
|
||||
print('AutoSyncService: Cancelled pending sync and retry timer');
|
||||
printLog('AutoSyncService: Cancelled pending sync and retry timer');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,7 +427,7 @@ class AutoSyncService {
|
||||
_retryTimer = null;
|
||||
|
||||
if (kDebugMode) {
|
||||
print('AutoSyncService: Error state reset, auto-sync re-enabled');
|
||||
printLog('AutoSyncService: Error state reset, auto-sync re-enabled');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,7 +441,7 @@ class AutoSyncService {
|
||||
_recentErrors.clear();
|
||||
|
||||
if (kDebugMode) {
|
||||
print('AutoSyncService: Disposed');
|
||||
printLog('AutoSyncService: Disposed');
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:supplements/logging.dart';
|
||||
import 'package:webdav_client/webdav_client.dart';
|
||||
|
||||
import '../models/supplement.dart';
|
||||
@@ -92,7 +93,7 @@ class DatabaseSyncService {
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('SupplementsLog: Error loading saved sync configuration: $e');
|
||||
printLog('Error loading saved sync configuration: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,7 +108,7 @@ class DatabaseSyncService {
|
||||
if (_configuredRemotePath != null) await prefs.setString(_keyRemotePath, _configuredRemotePath!);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('SupplementsLog: Error saving sync configuration: $e');
|
||||
printLog('Error saving sync configuration: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,7 +146,7 @@ class DatabaseSyncService {
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('SupplementsLog: Connection test failed: $e');
|
||||
printLog('Connection test failed: $e');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -182,7 +183,7 @@ class DatabaseSyncService {
|
||||
_setStatus(SyncStatus.error);
|
||||
onError?.call(_lastError!);
|
||||
if (kDebugMode) {
|
||||
print('SupplementsLog: Sync failed: $e');
|
||||
printLog('Sync failed: $e');
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
@@ -196,13 +197,13 @@ class DatabaseSyncService {
|
||||
|
||||
if (!remoteDbExists) {
|
||||
if (kDebugMode) {
|
||||
print('SupplementsLog: No remote database found, will upload local database');
|
||||
printLog('No remote database found, will upload local database');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
print('SupplementsLog: Remote database found, downloading...');
|
||||
printLog('Remote database found, downloading...');
|
||||
}
|
||||
|
||||
// Download the remote database
|
||||
@@ -217,14 +218,14 @@ class DatabaseSyncService {
|
||||
await tempFile.writeAsBytes(remoteDbBytes);
|
||||
|
||||
if (kDebugMode) {
|
||||
print('SupplementsLog: Downloaded remote database (${remoteDbBytes.length} bytes) to: $tempDbPath');
|
||||
printLog('Downloaded remote database (${remoteDbBytes.length} bytes) to: $tempDbPath');
|
||||
}
|
||||
|
||||
return tempDbPath;
|
||||
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('SupplementsLog: Failed to download remote database: $e');
|
||||
printLog('Failed to download remote database: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -233,13 +234,13 @@ class DatabaseSyncService {
|
||||
Future<void> _mergeDatabases(String? remoteDbPath) async {
|
||||
if (remoteDbPath == null) {
|
||||
if (kDebugMode) {
|
||||
print('SupplementsLog: No remote database to merge');
|
||||
printLog('No remote database to merge');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
print('SupplementsLog: Starting database merge from: $remoteDbPath');
|
||||
printLog('Starting database merge from: $remoteDbPath');
|
||||
}
|
||||
|
||||
final localDb = await _databaseHelper.database;
|
||||
@@ -249,21 +250,21 @@ class DatabaseSyncService {
|
||||
// Check what tables exist in remote database
|
||||
if (kDebugMode) {
|
||||
final tables = await remoteDb.rawQuery("SELECT name FROM sqlite_master WHERE type='table'");
|
||||
print('SupplementsLog: Remote database tables: ${tables.map((t) => t['name']).toList()}');
|
||||
printLog('Remote database tables: ${tables.map((t) => t['name']).toList()}');
|
||||
|
||||
// Count records in each table
|
||||
try {
|
||||
final supplementCount = await remoteDb.rawQuery('SELECT COUNT(*) as count FROM supplements');
|
||||
print('SupplementsLog: Remote supplements count: ${supplementCount.first['count']}');
|
||||
printLog('Remote supplements count: ${supplementCount.first['count']}');
|
||||
} catch (e) {
|
||||
print('SupplementsLog: Error counting supplements: $e');
|
||||
printLog('Error counting supplements: $e');
|
||||
}
|
||||
|
||||
try {
|
||||
final intakeCount = await remoteDb.rawQuery('SELECT COUNT(*) as count FROM supplement_intakes');
|
||||
print('SupplementsLog: Remote intakes count: ${intakeCount.first['count']}');
|
||||
printLog('Remote intakes count: ${intakeCount.first['count']}');
|
||||
} catch (e) {
|
||||
print('SupplementsLog: Error counting intakes: $e');
|
||||
printLog('Error counting intakes: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,7 +275,7 @@ class DatabaseSyncService {
|
||||
await _mergeIntakes(localDb, remoteDb);
|
||||
|
||||
if (kDebugMode) {
|
||||
print('SupplementsLog: Database merge completed successfully');
|
||||
printLog('Database merge completed successfully');
|
||||
}
|
||||
|
||||
} finally {
|
||||
@@ -284,7 +285,7 @@ class DatabaseSyncService {
|
||||
|
||||
Future<void> _mergeSupplements(Database localDb, Database remoteDb) async {
|
||||
if (kDebugMode) {
|
||||
print('SupplementsLog: Starting supplement merge...');
|
||||
printLog('Starting supplement merge...');
|
||||
}
|
||||
|
||||
// Get all supplements from remote database
|
||||
@@ -293,19 +294,19 @@ class DatabaseSyncService {
|
||||
remoteMaps.map((map) => Supplement.fromMap(map)).toList();
|
||||
|
||||
if (kDebugMode) {
|
||||
print(
|
||||
'SupplementsLog: Found ${remoteSupplements.length} supplements in remote database');
|
||||
printLog(
|
||||
'Found ${remoteSupplements.length} supplements in remote database');
|
||||
for (final supplement in remoteSupplements) {
|
||||
print(
|
||||
'SupplementsLog: Remote supplement: ${supplement.name} (syncId: ${supplement.syncId}, deleted: ${supplement.isDeleted})');
|
||||
printLog(
|
||||
'Remote supplement: ${supplement.name} (syncId: ${supplement.syncId}, deleted: ${supplement.isDeleted})');
|
||||
}
|
||||
}
|
||||
|
||||
for (final remoteSupplement in remoteSupplements) {
|
||||
if (remoteSupplement.syncId.isEmpty) {
|
||||
if (kDebugMode) {
|
||||
print(
|
||||
'SupplementsLog: Skipping supplement ${remoteSupplement.name} - no syncId');
|
||||
printLog(
|
||||
'Skipping supplement ${remoteSupplement.name} - no syncId');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -325,13 +326,13 @@ class DatabaseSyncService {
|
||||
mapToInsert.remove('id');
|
||||
await localDb.insert('supplements', mapToInsert);
|
||||
if (kDebugMode) {
|
||||
print(
|
||||
'SupplementsLog: ✓ Inserted new supplement: ${remoteSupplement.name}');
|
||||
printLog(
|
||||
'✓ Inserted new supplement: ${remoteSupplement.name}');
|
||||
}
|
||||
} else {
|
||||
if (kDebugMode) {
|
||||
print(
|
||||
'SupplementsLog: Skipping deleted supplement: ${remoteSupplement.name}');
|
||||
printLog(
|
||||
'Skipping deleted supplement: ${remoteSupplement.name}');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -349,26 +350,26 @@ class DatabaseSyncService {
|
||||
whereArgs: [existingSupplement.id],
|
||||
);
|
||||
if (kDebugMode) {
|
||||
print(
|
||||
'SupplementsLog: ✓ Updated supplement: ${remoteSupplement.name}');
|
||||
printLog(
|
||||
'✓ Updated supplement: ${remoteSupplement.name}');
|
||||
}
|
||||
} else {
|
||||
if (kDebugMode) {
|
||||
print(
|
||||
'SupplementsLog: Local supplement ${remoteSupplement.name} is newer, keeping local version');
|
||||
printLog(
|
||||
'Local supplement ${remoteSupplement.name} is newer, keeping local version');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
print('SupplementsLog: Supplement merge completed');
|
||||
printLog('Supplement merge completed');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _mergeIntakes(Database localDb, Database remoteDb) async {
|
||||
if (kDebugMode) {
|
||||
print('SupplementsLog: Starting intake merge...');
|
||||
printLog('Starting intake merge...');
|
||||
}
|
||||
|
||||
// Get all intakes from remote database
|
||||
@@ -376,13 +377,13 @@ class DatabaseSyncService {
|
||||
final remoteIntakes = remoteMaps.map((map) => SupplementIntake.fromMap(map)).toList();
|
||||
|
||||
if (kDebugMode) {
|
||||
print('SupplementsLog: Found ${remoteIntakes.length} intakes in remote database');
|
||||
printLog('Found ${remoteIntakes.length} intakes in remote database');
|
||||
}
|
||||
|
||||
for (final remoteIntake in remoteIntakes) {
|
||||
if (remoteIntake.syncId.isEmpty) {
|
||||
if (kDebugMode) {
|
||||
print('SupplementsLog: Skipping intake - no syncId');
|
||||
printLog('Skipping intake - no syncId');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -405,16 +406,16 @@ class DatabaseSyncService {
|
||||
);
|
||||
await localDb.insert('supplement_intakes', intakeToInsert.toMap());
|
||||
if (kDebugMode) {
|
||||
print('SupplementsLog: ✓ Inserted new intake: ${remoteIntake.syncId}');
|
||||
printLog('✓ Inserted new intake: ${remoteIntake.syncId}');
|
||||
}
|
||||
} else {
|
||||
if (kDebugMode) {
|
||||
print('SupplementsLog: Could not find local supplement for intake ${remoteIntake.syncId}');
|
||||
printLog('Could not find local supplement for intake ${remoteIntake.syncId}');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (kDebugMode) {
|
||||
print('SupplementsLog: Skipping deleted intake: ${remoteIntake.syncId}');
|
||||
printLog('Skipping deleted intake: ${remoteIntake.syncId}');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -430,18 +431,18 @@ class DatabaseSyncService {
|
||||
whereArgs: [existingIntake.id],
|
||||
);
|
||||
if (kDebugMode) {
|
||||
print('SupplementsLog: ✓ Updated intake: ${remoteIntake.syncId}');
|
||||
printLog('✓ Updated intake: ${remoteIntake.syncId}');
|
||||
}
|
||||
} else {
|
||||
if (kDebugMode) {
|
||||
print('SupplementsLog: Local intake ${remoteIntake.syncId} is newer, keeping local version');
|
||||
printLog('Local intake ${remoteIntake.syncId} is newer, keeping local version');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
print('SupplementsLog: Intake merge completed');
|
||||
printLog('Intake merge completed');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,7 +477,7 @@ class DatabaseSyncService {
|
||||
final dbPath = localDb.path;
|
||||
|
||||
if (kDebugMode) {
|
||||
print('SupplementsLog: Reading database from: $dbPath');
|
||||
printLog('Reading database from: $dbPath');
|
||||
}
|
||||
|
||||
// Read the database file
|
||||
@@ -488,7 +489,7 @@ class DatabaseSyncService {
|
||||
final dbBytes = await dbFile.readAsBytes();
|
||||
|
||||
if (kDebugMode) {
|
||||
print('SupplementsLog: Database file size: ${dbBytes.length} bytes');
|
||||
printLog('Database file size: ${dbBytes.length} bytes');
|
||||
}
|
||||
|
||||
if (dbBytes.isEmpty) {
|
||||
@@ -500,7 +501,7 @@ class DatabaseSyncService {
|
||||
await _client!.readDir(_remotePath!);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('SupplementsLog: Creating remote directory: $_remotePath');
|
||||
printLog('Creating remote directory: $_remotePath');
|
||||
}
|
||||
await _client!.mkdir(_remotePath!);
|
||||
}
|
||||
@@ -510,12 +511,12 @@ class DatabaseSyncService {
|
||||
await _client!.write(remoteUrl, dbBytes);
|
||||
|
||||
if (kDebugMode) {
|
||||
print('SupplementsLog: Successfully uploaded database (${dbBytes.length} bytes) to: $remoteUrl');
|
||||
printLog('Successfully uploaded database (${dbBytes.length} bytes) to: $remoteUrl');
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('SupplementsLog: Failed to upload database: $e');
|
||||
printLog('Failed to upload database: $e');
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
|
163
lib/services/notification_router.dart
Normal file
163
lib/services/notification_router.dart
Normal file
@@ -0,0 +1,163 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:supplements/logging.dart';
|
||||
|
||||
import '../models/supplement.dart';
|
||||
import '../providers/supplement_provider.dart';
|
||||
import '../widgets/dialogs/bulk_take_dialog.dart';
|
||||
import '../widgets/dialogs/take_supplement_dialog.dart';
|
||||
|
||||
/// Centralizes routing from notification actions/taps to in-app UI.
|
||||
/// Handles both foreground/background taps and terminated-launch scenarios.
|
||||
class NotificationRouter {
|
||||
NotificationRouter._internal();
|
||||
static final NotificationRouter instance = NotificationRouter._internal();
|
||||
|
||||
GlobalKey<NavigatorState>? _navigatorKey;
|
||||
|
||||
void initialize(GlobalKey<NavigatorState> navigatorKey) {
|
||||
_navigatorKey = navigatorKey;
|
||||
}
|
||||
|
||||
Future<void> handleNotificationResponse(NotificationResponse response) async {
|
||||
final payloadMap = _decodePayload(response.payload);
|
||||
final actionId = response.actionId;
|
||||
printLog('🔔 handleNotificationResponse: actionId=$actionId payload=${response.payload} map=$payloadMap');
|
||||
|
||||
await _routeFromPayload(payloadMap);
|
||||
}
|
||||
|
||||
Future<void> handleAppLaunchDetails(NotificationAppLaunchDetails? details) async {
|
||||
if (details == null) return;
|
||||
if (!details.didNotificationLaunchApp) return;
|
||||
|
||||
final resp = details.notificationResponse;
|
||||
final payloadMap = _decodePayload(resp?.payload);
|
||||
printLog('🚀 App launched from notification: payload=${resp?.payload} map=$payloadMap');
|
||||
|
||||
await _routeFromPayload(payloadMap);
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _decodePayload(String? payload) {
|
||||
if (payload == null || payload.isEmpty) return null;
|
||||
|
||||
// Try JSON first
|
||||
try {
|
||||
final map = jsonDecode(payload);
|
||||
if (map is Map<String, dynamic>) return map;
|
||||
} catch (_) {
|
||||
// Ignore and try fallback
|
||||
}
|
||||
|
||||
// Fallback: previous implementation used HH:mm as raw payload
|
||||
final hhmm = RegExp(r'^\d{2}:\d{2}$');
|
||||
if (hhmm.hasMatch(payload)) {
|
||||
return { 'type': 'group', 'time': payload };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _routeFromPayload(Map<String, dynamic>? payload) async {
|
||||
if (_navigatorKey == null) {
|
||||
printLog('⚠️ NotificationRouter not initialized with navigatorKey');
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait until navigator is ready and providers have loaded
|
||||
final ready = await _waitUntilReady(timeout: const Duration(seconds: 5));
|
||||
if (!ready) {
|
||||
printLog('⚠️ Timeout waiting for app to be ready for routing');
|
||||
return;
|
||||
}
|
||||
|
||||
final context = _navigatorKey!.currentContext!;
|
||||
final provider = context.read<SupplementProvider>();
|
||||
|
||||
if (payload == null) {
|
||||
printLog('⚠️ No payload to route');
|
||||
return;
|
||||
}
|
||||
|
||||
final type = payload['type'];
|
||||
if (type == 'single') {
|
||||
final id = payload['id'];
|
||||
if (id is int) {
|
||||
Supplement? s;
|
||||
try {
|
||||
s = provider.supplements.firstWhere((el) => el.id == id);
|
||||
} catch (_) {
|
||||
s = null;
|
||||
}
|
||||
if (s == null) {
|
||||
// Attempt reload once
|
||||
await provider.loadSupplements();
|
||||
try {
|
||||
s = provider.supplements.firstWhere((el) => el.id == id);
|
||||
} catch (_) {
|
||||
s = null;
|
||||
}
|
||||
}
|
||||
if (s != null) {
|
||||
// For single: use the regular dialog (with time selection)
|
||||
// Ensure we close any existing dialog first
|
||||
_popAnyDialog(context);
|
||||
await showTakeSupplementDialog(context, s, hideTime: false);
|
||||
} else {
|
||||
printLog('⚠️ Supplement id=$id not found for single-take routing');
|
||||
_showSnack(context, 'Supplement not found');
|
||||
}
|
||||
}
|
||||
} else if (type == 'group') {
|
||||
final timeKey = payload['time'];
|
||||
if (timeKey is String) {
|
||||
// Build list of supplements scheduled at this timeKey
|
||||
final List<Supplement> list = provider.supplements.where((s) {
|
||||
return s.isActive && s.reminderTimes.contains(timeKey);
|
||||
}).toList();
|
||||
|
||||
if (list.isEmpty) {
|
||||
printLog('⚠️ No supplements found for group time=$timeKey');
|
||||
_showSnack(context, 'No supplements for $timeKey');
|
||||
return;
|
||||
}
|
||||
|
||||
_popAnyDialog(context);
|
||||
await showBulkTakeDialog(context, list);
|
||||
}
|
||||
} else {
|
||||
printLog('⚠️ Unknown payload type: $type');
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _waitUntilReady({required Duration timeout}) async {
|
||||
final start = DateTime.now();
|
||||
while (DateTime.now().difference(start) < timeout) {
|
||||
final ctx = _navigatorKey!.currentContext;
|
||||
if (ctx != null) {
|
||||
final provider = Provider.of<SupplementProvider>(ctx, listen: false);
|
||||
if (!provider.isLoading) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void _popAnyDialog(BuildContext context) {
|
||||
if (Navigator.of(context, rootNavigator: true).canPop()) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
}
|
||||
}
|
||||
|
||||
void _showSnack(BuildContext context, String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
}
|
||||
}
|
@@ -1,704 +1,11 @@
|
||||
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';
|
||||
/*
|
||||
Deprecated/removed: notification_service.dart
|
||||
|
||||
// Top-level function to handle notification responses when app is running
|
||||
@pragma('vm:entry-point')
|
||||
void notificationTapBackground(NotificationResponse notificationResponse) {
|
||||
print('SupplementsLog: 📱 === BACKGROUND NOTIFICATION RESPONSE ===');
|
||||
print('SupplementsLog: 📱 Action ID: ${notificationResponse.actionId}');
|
||||
print('SupplementsLog: 📱 Payload: ${notificationResponse.payload}');
|
||||
print('SupplementsLog: 📱 Notification ID: ${notificationResponse.id}');
|
||||
print('SupplementsLog: 📱 ==========================================');
|
||||
This legacy notification service has been intentionally removed.
|
||||
The app now uses a minimal scheduler in:
|
||||
services/simple_notification_service.dart
|
||||
|
||||
// For now, just log the action. The main app handler will process it.
|
||||
if (notificationResponse.actionId == 'take_supplement') {
|
||||
print('SupplementsLog: 📱 BACKGROUND: Take action detected');
|
||||
} else if (notificationResponse.actionId == 'snooze_10') {
|
||||
print('SupplementsLog: 📱 BACKGROUND: Snooze action detected');
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationService {
|
||||
static final NotificationService _instance = NotificationService._internal();
|
||||
factory NotificationService() => _instance;
|
||||
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 {
|
||||
print('SupplementsLog: 📱 Initializing NotificationService...');
|
||||
if (_isInitialized) {
|
||||
print('SupplementsLog: 📱 Already initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
print('SupplementsLog: 📱 Initializing timezones...');
|
||||
print('SupplementsLog: 📱 Engine initialized flag: $_engineInitialized');
|
||||
|
||||
if (!_engineInitialized) {
|
||||
tz.initializeTimeZones();
|
||||
_engineInitialized = true;
|
||||
print('SupplementsLog: 📱 Timezones initialized successfully');
|
||||
} else {
|
||||
print('SupplementsLog: 📱 Timezones already initialized, skipping');
|
||||
}
|
||||
} catch (e) {
|
||||
print('SupplementsLog: 📱 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('SupplementsLog: 📱 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('SupplementsLog: 📱 Could not find timezone $timeZoneName, using Europe/Amsterdam as default');
|
||||
location = tz.getLocation('Europe/Amsterdam');
|
||||
}
|
||||
}
|
||||
|
||||
tz.setLocalLocation(location);
|
||||
print('SupplementsLog: 📱 Timezone set to: ${location.name}');
|
||||
|
||||
} catch (e) {
|
||||
print('SupplementsLog: 📱 Error setting timezone: $e, using default');
|
||||
// Fallback to a reasonable default for Netherlands
|
||||
tz.setLocalLocation(tz.getLocation('Europe/Amsterdam'));
|
||||
}
|
||||
|
||||
print('SupplementsLog: 📱 Current local time: ${tz.TZDateTime.now(tz.local)}');
|
||||
print('SupplementsLog: 📱 Current system time: ${DateTime.now()}');
|
||||
|
||||
const AndroidInitializationSettings androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const DarwinInitializationSettings iosSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: false, // We'll request these separately
|
||||
requestBadgePermission: false,
|
||||
requestSoundPermission: false,
|
||||
);
|
||||
const LinuxInitializationSettings linuxSettings = LinuxInitializationSettings(
|
||||
defaultActionName: 'Open notification',
|
||||
);
|
||||
|
||||
const InitializationSettings initSettings = InitializationSettings(
|
||||
android: androidSettings,
|
||||
iOS: iosSettings,
|
||||
linux: linuxSettings,
|
||||
);
|
||||
|
||||
print('SupplementsLog: 📱 Initializing flutter_local_notifications...');
|
||||
await _notifications.initialize(
|
||||
initSettings,
|
||||
onDidReceiveNotificationResponse: _onNotificationResponse,
|
||||
onDidReceiveBackgroundNotificationResponse: notificationTapBackground,
|
||||
);
|
||||
|
||||
// Test if notification response callback is working
|
||||
print('SupplementsLog: 📱 Callback function is set and ready');
|
||||
|
||||
_isInitialized = true;
|
||||
print('SupplementsLog: 📱 NotificationService initialization complete');
|
||||
}
|
||||
|
||||
// Handle notification responses (when user taps on notification or action)
|
||||
void _onNotificationResponse(NotificationResponse response) {
|
||||
print('SupplementsLog: 📱 === NOTIFICATION RESPONSE ===');
|
||||
print('SupplementsLog: 📱 Action ID: ${response.actionId}');
|
||||
print('SupplementsLog: 📱 Payload: ${response.payload}');
|
||||
print('SupplementsLog: 📱 Notification ID: ${response.id}');
|
||||
print('SupplementsLog: 📱 Input: ${response.input}');
|
||||
print('SupplementsLog: 📱 ===============================');
|
||||
|
||||
if (response.actionId == 'take_supplement') {
|
||||
print('SupplementsLog: 📱 Processing TAKE action...');
|
||||
_handleTakeAction(response.payload, response.id);
|
||||
} else if (response.actionId == 'snooze_10') {
|
||||
print('SupplementsLog: 📱 Processing SNOOZE action...');
|
||||
_handleSnoozeAction(response.payload, 10, response.id);
|
||||
} else {
|
||||
print('SupplementsLog: 📱 Default notification tap (no specific action)');
|
||||
// Default tap (no actionId) opens the app normally
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleTakeAction(String? payload, int? notificationId) async {
|
||||
print('SupplementsLog: 📱 === HANDLING TAKE ACTION ===');
|
||||
print('SupplementsLog: 📱 Payload received: $payload');
|
||||
|
||||
if (payload != null) {
|
||||
try {
|
||||
// Parse the payload to get supplement info
|
||||
final parts = payload.split('|');
|
||||
print('SupplementsLog: 📱 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('SupplementsLog: 📱 Parsed data:');
|
||||
print('SupplementsLog: 📱 - ID: $supplementId');
|
||||
print('SupplementsLog: 📱 - Name: $supplementName');
|
||||
print('SupplementsLog: 📱 - Units: $units');
|
||||
print('SupplementsLog: 📱 - Type: $unitType');
|
||||
|
||||
// Call the callback to record the intake
|
||||
if (_onTakeSupplementCallback != null) {
|
||||
print('SupplementsLog: 📱 Calling supplement callback...');
|
||||
_onTakeSupplementCallback!(
|
||||
supplementId, supplementName, units, unitType);
|
||||
print('SupplementsLog: 📱 Callback completed');
|
||||
} else {
|
||||
print('SupplementsLog: 📱 ERROR: No callback registered!');
|
||||
}
|
||||
|
||||
// For retry notifications, the original notification ID is in the payload
|
||||
int originalNotificationId;
|
||||
if (parts.length > 4 && int.tryParse(parts[4]) != null) {
|
||||
originalNotificationId = int.parse(parts[4]);
|
||||
print(
|
||||
'SupplementsLog: 📱 Retry notification detected. Original ID: $originalNotificationId');
|
||||
} else if (notificationId != null) {
|
||||
originalNotificationId = notificationId;
|
||||
} else {
|
||||
print(
|
||||
'SupplementsLog: 📱 ERROR: Could not determine notification ID to cancel.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark notification as taken in database (this will cancel any pending retries)
|
||||
print(
|
||||
'SupplementsLog: 📱 Marking notification $originalNotificationId as taken');
|
||||
await DatabaseHelper.instance
|
||||
.markNotificationTaken(originalNotificationId);
|
||||
|
||||
// Cancel any pending retry notifications for this notification
|
||||
_cancelRetryNotifications(originalNotificationId);
|
||||
|
||||
// Show a confirmation notification
|
||||
print('SupplementsLog: 📱 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(
|
||||
'SupplementsLog: 📱 ERROR: Invalid payload format - not enough parts');
|
||||
}
|
||||
} catch (e) {
|
||||
print('SupplementsLog: 📱 ERROR in _handleTakeAction: $e');
|
||||
}
|
||||
} else {
|
||||
print('SupplementsLog: 📱 ERROR: Payload is null');
|
||||
}
|
||||
print('SupplementsLog: 📱 === 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('SupplementsLog: 📱 Cancelled retry notification ID: $retryId');
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSnoozeAction(String? payload, int minutes, int? notificationId) {
|
||||
print('SupplementsLog: 📱 === HANDLING SNOOZE ACTION ===');
|
||||
print('SupplementsLog: 📱 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('SupplementsLog: 📱 Snoozing supplement for $minutes minutes: $supplementName');
|
||||
|
||||
// Mark notification as snoozed in database (increment retry count)
|
||||
if (notificationId != null) {
|
||||
print('SupplementsLog: 📱 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('SupplementsLog: 📱 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('SupplementsLog: 📱 Snooze scheduled successfully');
|
||||
}
|
||||
} catch (e) {
|
||||
print('SupplementsLog: 📱 Error handling snooze action: $e');
|
||||
}
|
||||
}
|
||||
print('SupplementsLog: 📱 === 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('SupplementsLog: 📱 Checking for pending notifications to retry...');
|
||||
|
||||
try {
|
||||
if (!persistentReminders) {
|
||||
print('SupplementsLog: 📱 Persistent reminders disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
print('SupplementsLog: 📱 Retry settings: interval=$reminderRetryInterval min, max=$maxRetryAttempts attempts');
|
||||
|
||||
// Get all pending notifications from database
|
||||
final pendingNotifications = await DatabaseHelper.instance.getPendingNotifications();
|
||||
print('SupplementsLog: 📱 Found ${pendingNotifications.length} pending notifications');
|
||||
|
||||
final now = DateTime.now();
|
||||
|
||||
for (final notification in pendingNotifications) {
|
||||
final scheduledTime = DateTime.parse(notification['scheduledTime']).toLocal();
|
||||
final retryCount = notification['retryCount'] as int;
|
||||
final lastRetryTime = notification['lastRetryTime'] != null
|
||||
? DateTime.parse(notification['lastRetryTime']).toLocal()
|
||||
: null;
|
||||
|
||||
// Check if notification is overdue
|
||||
final timeSinceScheduled = now.difference(scheduledTime).inMinutes;
|
||||
final shouldRetry = timeSinceScheduled >= reminderRetryInterval;
|
||||
|
||||
print('SupplementsLog: 📱 Checking notification ${notification['notificationId']}:');
|
||||
print('SupplementsLog: 📱 Scheduled: $scheduledTime (local)');
|
||||
print('SupplementsLog: 📱 Now: $now');
|
||||
print('SupplementsLog: 📱 Time since scheduled: $timeSinceScheduled minutes');
|
||||
print('SupplementsLog: 📱 Retry interval: $reminderRetryInterval minutes');
|
||||
print('SupplementsLog: 📱 Should retry: $shouldRetry');
|
||||
print('SupplementsLog: 📱 Retry count: $retryCount / $maxRetryAttempts');
|
||||
|
||||
// Check if we haven't exceeded max retry attempts
|
||||
if (retryCount >= maxRetryAttempts) {
|
||||
print('SupplementsLog: 📱 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('SupplementsLog: 📱 Notification ${notification['notificationId']} not ready for retry yet');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRetry) {
|
||||
print('SupplementsLog: 📱 ⚡ SCHEDULING RETRY for notification ${notification['notificationId']}');
|
||||
await _scheduleRetryNotification(notification, retryCount + 1);
|
||||
} else {
|
||||
print('SupplementsLog: 📱 ⏸️ NOT READY FOR RETRY: ${notification['notificationId']}');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('SupplementsLog: 📱 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('SupplementsLog: 📱 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,
|
||||
),
|
||||
AndroidNotificationAction(
|
||||
'snooze_10',
|
||||
'Snooze 10min',
|
||||
showsUserInterface: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
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('SupplementsLog: 📱 Retry notification scheduled successfully');
|
||||
} catch (e) {
|
||||
print('SupplementsLog: 📱 Error scheduling retry notification: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> requestPermissions() async {
|
||||
print('SupplementsLog: 📱 Requesting notification permissions...');
|
||||
if (_permissionsRequested) {
|
||||
print('SupplementsLog: 📱 Permissions already requested');
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
_permissionsRequested = true;
|
||||
|
||||
final androidPlugin = _notifications.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
|
||||
if (androidPlugin != null) {
|
||||
print('SupplementsLog: 📱 Requesting Android permissions...');
|
||||
final granted = await androidPlugin.requestNotificationsPermission();
|
||||
print('SupplementsLog: 📱 Android permissions granted: $granted');
|
||||
if (granted != true) {
|
||||
_permissionsRequested = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
final iosPlugin = _notifications.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>();
|
||||
if (iosPlugin != null) {
|
||||
print('SupplementsLog: 📱 Requesting iOS permissions...');
|
||||
final granted = await iosPlugin.requestPermissions(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
);
|
||||
print('SupplementsLog: 📱 iOS permissions granted: $granted');
|
||||
if (granted != true) {
|
||||
_permissionsRequested = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
print('SupplementsLog: 📱 All permissions granted successfully');
|
||||
return true;
|
||||
} catch (e) {
|
||||
_permissionsRequested = false;
|
||||
print('SupplementsLog: 📱 Error requesting permissions: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> scheduleSupplementReminders(Supplement supplement) async {
|
||||
print('SupplementsLog: 📱 Scheduling reminders for ${supplement.name}');
|
||||
print('SupplementsLog: 📱 Reminder times: ${supplement.reminderTimes}');
|
||||
|
||||
// Cancel existing notifications for this supplement
|
||||
await cancelSupplementReminders(supplement.id!);
|
||||
|
||||
for (int i = 0; i < supplement.reminderTimes.length; i++) {
|
||||
final timeStr = supplement.reminderTimes[i];
|
||||
final timeParts = timeStr.split(':');
|
||||
final hour = int.parse(timeParts[0]);
|
||||
final minute = int.parse(timeParts[1]);
|
||||
|
||||
final notificationId = supplement.id! * 100 + i; // Unique ID for each reminder
|
||||
final scheduledTime = _nextInstanceOfTime(hour, minute);
|
||||
|
||||
print('SupplementsLog: 📱 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.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: const DarwinNotificationDetails(),
|
||||
),
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
matchDateTimeComponents: DateTimeComponents.time,
|
||||
payload: '${supplement.id}|${supplement.name}|${supplement.numberOfUnits}|${supplement.unitType}',
|
||||
);
|
||||
|
||||
print('SupplementsLog: 📱 Successfully scheduled notification ID $notificationId');
|
||||
}
|
||||
|
||||
// Get all pending notifications to verify
|
||||
final pendingNotifications = await _notifications.pendingNotificationRequests();
|
||||
print('SupplementsLog: 📱 Total pending notifications: ${pendingNotifications.length}');
|
||||
for (final notification in pendingNotifications) {
|
||||
print('SupplementsLog: 📱 Pending: ID=${notification.id}, Title=${notification.title}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelSupplementReminders(int supplementId) async {
|
||||
// Cancel all notifications for this supplement (up to 10 possible reminders)
|
||||
for (int i = 0; i < 10; i++) {
|
||||
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 {
|
||||
await _notifications.cancelAll();
|
||||
}
|
||||
|
||||
tz.TZDateTime _nextInstanceOfTime(int hour, int minute) {
|
||||
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('SupplementsLog: 📱 Current time: $now (${now.timeZoneName})');
|
||||
print('SupplementsLog: 📱 Target time: ${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}');
|
||||
print('SupplementsLog: 📱 Initial scheduled date: $scheduledDate (${scheduledDate.timeZoneName})');
|
||||
|
||||
if (scheduledDate.isBefore(now)) {
|
||||
scheduledDate = scheduledDate.add(const Duration(days: 1));
|
||||
print('SupplementsLog: 📱 Time has passed, scheduling for tomorrow: $scheduledDate (${scheduledDate.timeZoneName})');
|
||||
} else {
|
||||
print('SupplementsLog: 📱 Time is in the future, scheduling for today: $scheduledDate (${scheduledDate.timeZoneName})');
|
||||
}
|
||||
|
||||
return scheduledDate;
|
||||
}
|
||||
|
||||
Future<void> showInstantNotification(String title, String body) async {
|
||||
print('SupplementsLog: 📱 Showing instant notification: $title - $body');
|
||||
const NotificationDetails notificationDetails = NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'instant_notifications',
|
||||
'Instant Notifications',
|
||||
channelDescription: 'Instant notifications for supplement app',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
),
|
||||
iOS: DarwinNotificationDetails(),
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
title,
|
||||
body,
|
||||
notificationDetails,
|
||||
);
|
||||
print('SupplementsLog: 📱 Instant notification sent');
|
||||
}
|
||||
|
||||
// Debug function to test notifications
|
||||
Future<void> testNotification() async {
|
||||
print('SupplementsLog: 📱 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('SupplementsLog: 📱 Testing scheduled notification...');
|
||||
final now = tz.TZDateTime.now(tz.local);
|
||||
final testTime = now.add(const Duration(minutes: 1));
|
||||
|
||||
print('SupplementsLog: 📱 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('SupplementsLog: 📱 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('SupplementsLog: 📱 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('SupplementsLog: 📱 Test notification with actions created');
|
||||
}
|
||||
|
||||
// Debug function to test basic notification tap response
|
||||
Future<void> testBasicNotification() async {
|
||||
print('SupplementsLog: 📱 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('SupplementsLog: 📱 Basic test notification created');
|
||||
}
|
||||
}
|
||||
All retry/snooze/database-tracking logic has been dropped to keep things simple.
|
||||
This file is left empty to ensure any lingering references fail at compile time,
|
||||
prompting migration to the new SimpleNotificationService.
|
||||
*/
|
||||
|
@@ -1,40 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../models/nutrient.dart';
|
||||
|
||||
class NutrientDataService {
|
||||
static final NutrientDataService _instance = NutrientDataService._internal();
|
||||
|
||||
factory NutrientDataService() {
|
||||
return _instance;
|
||||
}
|
||||
|
||||
NutrientDataService._internal();
|
||||
|
||||
List<Nutrient>? _nutrients;
|
||||
|
||||
Future<List<Nutrient>> get nutrients async {
|
||||
if (_nutrients != null) {
|
||||
return _nutrients!;
|
||||
}
|
||||
await _loadNutrientData();
|
||||
return _nutrients!;
|
||||
}
|
||||
|
||||
Future<void> _loadNutrientData() async {
|
||||
try {
|
||||
final String response = await rootBundle.loadString('assets/canada_health.json');
|
||||
final data = await json.decode(response);
|
||||
final nutrientsData = data['nutrients'] as Map<String, dynamic>;
|
||||
|
||||
_nutrients = nutrientsData.entries.map((entry) {
|
||||
return Nutrient.fromJson(entry.key, entry.value);
|
||||
}).toList();
|
||||
} catch (e) {
|
||||
print('Error loading nutrient data: $e');
|
||||
_nutrients = [];
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,617 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import '../models/ingredient.dart';
|
||||
import '../models/nutrient.dart';
|
||||
import 'nutrient_data_service.dart';
|
||||
|
||||
/// Represents an RDA/AI match for a user and nutrient
|
||||
class RdaResult {
|
||||
final String nutrientKey;
|
||||
final String unitLabel; // e.g., "mg/day", "µg/day (RAE)"
|
||||
final String rdaType; // "RDA/AI" or "AI"
|
||||
final double value; // RDA/AI value in the units of unitLabel
|
||||
final double? valueMin; // Optional minimum recommended value
|
||||
final double? valueMax; // Optional maximum recommended value
|
||||
final double? ul; // Upper limit (if provided) in the same base units as unitLabel (life-stage)
|
||||
final String matchedLifeStageLabel; // e.g., "19-30 y", "51-70 y"
|
||||
final String? lifeStageDescription; // Optional description for the life stage (e.g., maintenance/loading)
|
||||
final UpperLimit? nutrientUl; // Nutrient-level UL (object with unit/duration/note), if available
|
||||
final String? note; // Optional dataset note (e.g., magnesium UL is supplemental only)
|
||||
|
||||
const RdaResult({
|
||||
required this.nutrientKey,
|
||||
required this.unitLabel,
|
||||
required this.rdaType,
|
||||
required this.value,
|
||||
this.valueMin,
|
||||
this.valueMax,
|
||||
required this.ul,
|
||||
required this.matchedLifeStageLabel,
|
||||
this.lifeStageDescription,
|
||||
this.nutrientUl,
|
||||
this.note,
|
||||
});
|
||||
}
|
||||
|
||||
/// Aggregated daily overview by nutrient
|
||||
class RdaAggregate {
|
||||
final String nutrientKey;
|
||||
final String unitLabel; // RDA unit label
|
||||
final double rdaValue; // Midpoint of range when available
|
||||
final double? rdaValueMin; // Optional minimum recommended value
|
||||
final double? rdaValueMax; // Optional maximum recommended value
|
||||
final double? ulValue;
|
||||
final double totalAmountInRdaUnit; // Total intake converted to RDA units
|
||||
final double percentOfRda; // 0..100+ (may exceed 100)
|
||||
final double? percentOfUl; // 0..100+ (may exceed 100)
|
||||
final String? matchedLifeStageLabel; // e.g., "19-30 y"
|
||||
final String? matchedLifeStageDescription; // Optional description for the life stage
|
||||
final String? rdaType; // e.g., "RDA/AI" or "AI"
|
||||
final UpperLimit? nutrientUl; // Nutrient-level UL object (unit/duration/note)
|
||||
final String? note; // Optional dataset note
|
||||
|
||||
const RdaAggregate({
|
||||
required this.nutrientKey,
|
||||
required this.unitLabel,
|
||||
required this.rdaValue,
|
||||
this.rdaValueMin,
|
||||
this.rdaValueMax,
|
||||
required this.ulValue,
|
||||
required this.totalAmountInRdaUnit,
|
||||
required this.percentOfRda,
|
||||
required this.percentOfUl,
|
||||
this.matchedLifeStageLabel,
|
||||
this.matchedLifeStageDescription,
|
||||
this.rdaType,
|
||||
this.nutrientUl,
|
||||
this.note,
|
||||
});
|
||||
}
|
||||
|
||||
/// Service for working with Health Canada DRIs (RDA/AI and UL)
|
||||
/// - Maps app ingredient names to nutrient keys in canada_health.json
|
||||
/// - Computes user-specific RDA/AI and UL values based on age and gender
|
||||
/// - Converts units and calculates % of RDA/AI and % of UL
|
||||
class RdaService {
|
||||
RdaService._internal();
|
||||
|
||||
static final RdaService _instance = RdaService._internal();
|
||||
|
||||
factory RdaService() => _instance;
|
||||
|
||||
final NutrientDataService _nutrientDataService = NutrientDataService();
|
||||
|
||||
// Cache nutrients by key: e.g., "vitamin_d"
|
||||
Map<String, Nutrient>? _nutrientsByKey;
|
||||
|
||||
// Known alias mapping for common ingredient names to nutrient keys
|
||||
// Keys must be lowercase for matching
|
||||
static const Map<String, String> _aliasToNutrientKey = {
|
||||
// Vitamin C
|
||||
'vitamin c': 'vitamin_c',
|
||||
'ascorbic acid': 'vitamin_c',
|
||||
|
||||
// Vitamin D
|
||||
'vitamin d': 'vitamin_d',
|
||||
'vitamin d3': 'vitamin_d',
|
||||
'cholecalciferol': 'vitamin_d',
|
||||
'vitamin d2': 'vitamin_d', // ergocalciferol - treat same RDA
|
||||
|
||||
// Vitamin A (RAE)
|
||||
'vitamin a': 'vitamin_a',
|
||||
'retinol': 'vitamin_a',
|
||||
'beta-carotene': 'vitamin_a',
|
||||
|
||||
// Vitamin E (alpha-tocopherol)
|
||||
'vitamin e': 'vitamin_e',
|
||||
'alpha tocopherol': 'vitamin_e',
|
||||
'alpha-tocopherol': 'vitamin_e',
|
||||
|
||||
// Vitamin K (K1/K2 common mapping to total Vitamin K AI)
|
||||
'vitamin k': 'vitamin_k',
|
||||
'vitamin k1': 'vitamin_k',
|
||||
'phylloquinone': 'vitamin_k',
|
||||
'vitamin k2': 'vitamin_k',
|
||||
'menaquinone': 'vitamin_k',
|
||||
|
||||
// B1 (Thiamine)
|
||||
'vitamin b1': 'vitamin_b1',
|
||||
'thiamine': 'vitamin_b1',
|
||||
'thiamin': 'vitamin_b1',
|
||||
|
||||
// B2 (Riboflavin)
|
||||
'vitamin b2': 'vitamin_b2',
|
||||
'riboflavin': 'vitamin_b2',
|
||||
|
||||
// Folate
|
||||
'folate': 'folate_dfe',
|
||||
'folic acid': 'folate_dfe',
|
||||
'folate (dfe)': 'folate_dfe',
|
||||
'dfe': 'folate_dfe',
|
||||
};
|
||||
|
||||
// RDA result and aggregate types moved to top-level (Dart doesn't support nested classes)
|
||||
|
||||
/// Get a user-specific RDA result for a given ingredient name.
|
||||
/// - Resolves the ingredient to a nutrient key using aliases and simple heuristics.
|
||||
/// - Computes the appropriate life-stage record based on age and gender.
|
||||
///
|
||||
/// If the ingredient doesn't map to a known nutrient or no life stage matches,
|
||||
/// returns null.
|
||||
Future<RdaResult?> getUserRdaForIngredient(
|
||||
String ingredientName, {
|
||||
DateTime? dateOfBirth,
|
||||
String? gender, // expected values similar to ['Male','Female','Other','Prefer not to say']
|
||||
}) async {
|
||||
final key = await mapIngredientToNutrientKey(ingredientName);
|
||||
if (key == null) return null;
|
||||
return getUserRdaForNutrientKey(
|
||||
key,
|
||||
dateOfBirth: dateOfBirth,
|
||||
gender: gender,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get a user-specific RDA result for a known nutrient key
|
||||
/// e.g., "vitamin_d", "vitamin_c".
|
||||
Future<RdaResult?> getUserRdaForNutrientKey(
|
||||
String nutrientKey, {
|
||||
DateTime? dateOfBirth,
|
||||
String? gender,
|
||||
}) async {
|
||||
final nutrient = await _getNutrientByKey(nutrientKey);
|
||||
if (nutrient == null) return null;
|
||||
|
||||
final _UserProfile profile = _UserProfile.from(dateOfBirth: dateOfBirth, gender: gender);
|
||||
final LifeStage? stage = _matchLifeStageForProfile(nutrient.lifeStages, profile);
|
||||
|
||||
if (stage == null) return null;
|
||||
|
||||
return RdaResult(
|
||||
nutrientKey: nutrientKey,
|
||||
unitLabel: nutrient.unit,
|
||||
rdaType: nutrient.rdaType,
|
||||
value: stage.value,
|
||||
valueMin: stage.valueMin,
|
||||
valueMax: stage.valueMax,
|
||||
ul: stage.ul,
|
||||
matchedLifeStageLabel: stage.ageRange,
|
||||
lifeStageDescription: stage.description,
|
||||
nutrientUl: nutrient.ul,
|
||||
note: nutrient.note,
|
||||
);
|
||||
}
|
||||
|
||||
/// Compute % of RDA and % of UL for a single ingredient dose.
|
||||
/// - Resolves ingredient to nutrient key
|
||||
/// - Converts the amount+unit to the RDA unit base for that nutrient
|
||||
/// - Calculates percent of RDA and UL
|
||||
///
|
||||
/// Returns null if the ingredient cannot be mapped or units cannot be converted.
|
||||
Future<RdaAggregate?> computePercentForDose(
|
||||
String ingredientName,
|
||||
double amount,
|
||||
String unit, {
|
||||
DateTime? dateOfBirth,
|
||||
String? gender,
|
||||
}) async {
|
||||
final rda = await getUserRdaForIngredient(
|
||||
ingredientName,
|
||||
dateOfBirth: dateOfBirth,
|
||||
gender: gender,
|
||||
);
|
||||
if (rda == null) return null;
|
||||
|
||||
final String rdaUnitSymbol = _unitSymbolFromLabel(rda.unitLabel); // "mg" or "ug"
|
||||
final String normalizedInputUnit = _normalizeUnit(unit);
|
||||
|
||||
final double? amountInRdaUnit = _convertAmountToTargetUnit(
|
||||
ingredientName: ingredientName,
|
||||
amount: amount,
|
||||
fromUnit: normalizedInputUnit,
|
||||
toUnit: rdaUnitSymbol,
|
||||
);
|
||||
|
||||
if (amountInRdaUnit == null) return null;
|
||||
|
||||
final double rdaForCalc = (rda.valueMin != null && rda.valueMax != null)
|
||||
? ((rda.valueMin! + rda.valueMax!) / 2.0)
|
||||
: rda.value;
|
||||
final double percentOfRda = (amountInRdaUnit / rdaForCalc) * 100.0;
|
||||
final double? percentOfUl =
|
||||
rda.ul != null && rda.ul! > 0 ? (amountInRdaUnit / rda.ul!) * 100.0 : null;
|
||||
|
||||
return RdaAggregate(
|
||||
nutrientKey: rda.nutrientKey,
|
||||
unitLabel: rda.unitLabel,
|
||||
rdaValue: rdaForCalc,
|
||||
rdaValueMin: rda.valueMin,
|
||||
rdaValueMax: rda.valueMax,
|
||||
ulValue: rda.ul,
|
||||
totalAmountInRdaUnit: amountInRdaUnit,
|
||||
percentOfRda: percentOfRda,
|
||||
percentOfUl: percentOfUl,
|
||||
matchedLifeStageLabel: rda.matchedLifeStageLabel,
|
||||
matchedLifeStageDescription: rda.lifeStageDescription,
|
||||
rdaType: rda.rdaType,
|
||||
nutrientUl: rda.nutrientUl,
|
||||
note: rda.note,
|
||||
);
|
||||
}
|
||||
|
||||
/// Aggregate multiple ingredients (e.g., full-day intake) into user-specific RDA overview.
|
||||
/// - Sums all ingredients mapped to the same nutrient
|
||||
/// - Converts units to the RDA base unit
|
||||
/// - Returns map keyed by nutrientKey
|
||||
Future<Map<String, RdaAggregate>> aggregateDailyIntake(
|
||||
List<Ingredient> ingredients, {
|
||||
DateTime? dateOfBirth,
|
||||
String? gender,
|
||||
}) async {
|
||||
final Map<String, double> totalsByNutrient = {};
|
||||
final Map<String, RdaResult> rdaByNutrient = {};
|
||||
|
||||
for (final ing in ingredients) {
|
||||
final key = await mapIngredientToNutrientKey(ing.name);
|
||||
if (key == null) continue;
|
||||
|
||||
// Ensure RDA is loaded for the nutrient
|
||||
rdaByNutrient[key] = rdaByNutrient[key] ??
|
||||
(await getUserRdaForNutrientKey(key, dateOfBirth: dateOfBirth, gender: gender))!;
|
||||
|
||||
final rda = rdaByNutrient[key];
|
||||
if (rda == null) continue; // no match for this nutrient
|
||||
|
||||
final String rdaUnitSymbol = _unitSymbolFromLabel(rda.unitLabel);
|
||||
final double? converted = _convertAmountToTargetUnit(
|
||||
ingredientName: ing.name,
|
||||
amount: ing.amount,
|
||||
fromUnit: _normalizeUnit(ing.unit),
|
||||
toUnit: rdaUnitSymbol,
|
||||
);
|
||||
if (converted == null) continue;
|
||||
|
||||
totalsByNutrient[key] = (totalsByNutrient[key] ?? 0.0) + converted;
|
||||
}
|
||||
|
||||
final Map<String, RdaAggregate> result = {};
|
||||
for (final entry in totalsByNutrient.entries) {
|
||||
final key = entry.key;
|
||||
final total = entry.value;
|
||||
final rda = rdaByNutrient[key];
|
||||
if (rda == null) continue;
|
||||
final double rdaForCalc = (rda.valueMin != null && rda.valueMax != null)
|
||||
? ((rda.valueMin! + rda.valueMax!) / 2.0)
|
||||
: rda.value;
|
||||
final double percentOfRda = (total / rdaForCalc) * 100.0;
|
||||
final double? percentOfUl = rda.ul != null && rda.ul! > 0 ? (total / rda.ul!) * 100.0 : null;
|
||||
|
||||
result[key] = RdaAggregate(
|
||||
nutrientKey: key,
|
||||
unitLabel: rda.unitLabel,
|
||||
rdaValue: rdaForCalc,
|
||||
rdaValueMin: rda.valueMin,
|
||||
rdaValueMax: rda.valueMax,
|
||||
ulValue: rda.ul,
|
||||
totalAmountInRdaUnit: total,
|
||||
percentOfRda: percentOfRda,
|
||||
percentOfUl: percentOfUl,
|
||||
matchedLifeStageLabel: rda.matchedLifeStageLabel,
|
||||
matchedLifeStageDescription: rda.lifeStageDescription,
|
||||
rdaType: rda.rdaType,
|
||||
nutrientUl: rda.nutrientUl,
|
||||
note: rda.note,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Map an ingredient name (e.g., "Vitamin D3") to a nutrient key (e.g., "vitamin_d") used in canada_health.json
|
||||
/// Returns null if no mapping is found.
|
||||
Future<String?> mapIngredientToNutrientKey(String ingredientName) async {
|
||||
await _ensureNutrientsLoaded();
|
||||
|
||||
final String cleaned = _normalizeIngredientName(ingredientName);
|
||||
|
||||
// Direct alias mapping
|
||||
final direct = _aliasToNutrientKey[cleaned];
|
||||
if (direct != null && _nutrientsByKey!.containsKey(direct)) return direct;
|
||||
|
||||
// Heuristic contains-based mapping
|
||||
if (cleaned.contains('vitamin d')) return _nutrientsByKey!.containsKey('vitamin_d') ? 'vitamin_d' : null;
|
||||
if (cleaned.contains('vitamin c')) return _nutrientsByKey!.containsKey('vitamin_c') ? 'vitamin_c' : null;
|
||||
if (cleaned.contains('vitamin a') || cleaned.contains('retinol') || cleaned.contains('beta carotene')) {
|
||||
return _nutrientsByKey!.containsKey('vitamin_a') ? 'vitamin_a' : null;
|
||||
}
|
||||
if (cleaned.contains('vitamin e') || cleaned.contains('alpha tocopherol') || cleaned.contains('alpha-tocopherol')) {
|
||||
return _nutrientsByKey!.containsKey('vitamin_e') ? 'vitamin_e' : null;
|
||||
}
|
||||
if (cleaned.contains('vitamin k') || cleaned.contains('phylloquinone') || cleaned.contains('menaquinone')) {
|
||||
return _nutrientsByKey!.containsKey('vitamin_k') ? 'vitamin_k' : null;
|
||||
}
|
||||
if (cleaned.contains('b1') || cleaned.contains('thiamin') || cleaned.contains('thiamine')) {
|
||||
return _nutrientsByKey!.containsKey('vitamin_b1') ? 'vitamin_b1' : null;
|
||||
}
|
||||
if (cleaned.contains('b2') || cleaned.contains('riboflavin')) {
|
||||
return _nutrientsByKey!.containsKey('vitamin_b2') ? 'vitamin_b2' : null;
|
||||
}
|
||||
if (cleaned.contains('folate') || cleaned.contains('folic')) {
|
||||
return _nutrientsByKey!.containsKey('folate_dfe') ? 'folate_dfe' : null;
|
||||
}
|
||||
if (cleaned.contains('vitamin b3') || cleaned.contains('niacin') || cleaned.contains('nicotinic acid') || cleaned.contains('niacinamide')) {
|
||||
return _nutrientsByKey!.containsKey('vitamin_b3') ? 'vitamin_b3' : null;
|
||||
}
|
||||
if (cleaned.contains('vitamin b5') || cleaned.contains('pantothenic')) {
|
||||
return _nutrientsByKey!.containsKey('vitamin_b5') ? 'vitamin_b5' : null;
|
||||
}
|
||||
if (cleaned.contains('vitamin b6') || cleaned.contains('pyridoxine')) {
|
||||
return _nutrientsByKey!.containsKey('vitamin_b6') ? 'vitamin_b6' : null;
|
||||
}
|
||||
if (cleaned.contains('vitamin b12') || cleaned.contains('cobalamin') || cleaned.contains('cyanocobalamin') || cleaned.contains('methylcobalamin')) {
|
||||
return _nutrientsByKey!.containsKey('vitamin_b12') ? 'vitamin_b12' : null;
|
||||
}
|
||||
if (cleaned.contains('magnesium')) {
|
||||
return _nutrientsByKey!.containsKey('magnesium') ? 'magnesium' : null;
|
||||
}
|
||||
if (cleaned.contains('zinc') || cleaned == 'zn') {
|
||||
return _nutrientsByKey!.containsKey('zinc') ? 'zinc' : null;
|
||||
}
|
||||
if (cleaned.contains('iron') || cleaned.contains('ferrous') || cleaned.contains('ferric')) {
|
||||
return _nutrientsByKey!.containsKey('iron') ? 'iron' : null;
|
||||
}
|
||||
if (cleaned.contains('creatine') || cleaned.contains('creapure') || cleaned.contains('creatine monohydrate')) {
|
||||
return _nutrientsByKey!.containsKey('creatine') ? 'creatine' : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// -----------------------
|
||||
// Internal helpers
|
||||
// -----------------------
|
||||
|
||||
Future<void> _ensureNutrientsLoaded() async {
|
||||
if (_nutrientsByKey != null) return;
|
||||
final list = await _nutrientDataService.nutrients;
|
||||
_nutrientsByKey = {for (final n in list) n.name: n};
|
||||
}
|
||||
|
||||
Future<Nutrient?> _getNutrientByKey(String key) async {
|
||||
await _ensureNutrientsLoaded();
|
||||
return _nutrientsByKey![key];
|
||||
}
|
||||
|
||||
// Normalize units (user input and stored ingredients)
|
||||
// Supported return values: "mg", "ug", "g", "iu", others returned as lowercased original (e.g., "ml")
|
||||
String _normalizeUnit(String unit) {
|
||||
final u = unit.trim().toLowerCase();
|
||||
if (u == 'mg') return 'mg';
|
||||
if (u == 'g' || u == 'gram' || u == 'grams') return 'g';
|
||||
if (u == 'µg' || u == 'μg' || u == 'mcg' || u == 'ug' || u == 'microgram' || u == 'micrograms') return 'ug';
|
||||
if (u == 'iu') return 'iu';
|
||||
return u; // e.g., "ml", "drops" etc. (unhandled for RDA calc)
|
||||
}
|
||||
|
||||
// Extract the base unit symbol ("mg" or "ug") from the dataset unit label (e.g., "µg/day (RAE)")
|
||||
String _unitSymbolFromLabel(String label) {
|
||||
final lower = label.toLowerCase();
|
||||
if (lower.startsWith('mg')) return 'mg';
|
||||
if (lower.startsWith('g')) return 'g';
|
||||
if (lower.startsWith('µg') || lower.startsWith('μg') || lower.startsWith('mcg')) return 'ug';
|
||||
// Fallback: assume microgram if unknown
|
||||
return 'ug';
|
||||
}
|
||||
|
||||
// Convert an amount from one unit to another.
|
||||
// Supported:
|
||||
// - mg <-> ug <-> g
|
||||
// - IU->ug for Vitamin D only (1 µg = 40 IU)
|
||||
// Returns null if conversion cannot be performed.
|
||||
double? _convertAmountToTargetUnit({
|
||||
required String ingredientName,
|
||||
required double amount,
|
||||
required String fromUnit,
|
||||
required String toUnit,
|
||||
}) {
|
||||
if (amount.isNaN || amount.isInfinite) return null;
|
||||
|
||||
// Handle IU conversions only for Vitamin D
|
||||
final name = _normalizeIngredientName(ingredientName);
|
||||
final isVitaminD = name.contains('vitamin d') || name.contains('cholecalciferol') || name.contains('ergocalciferol');
|
||||
|
||||
// If fromUnit equals toUnit and it's one of our supported numeric units
|
||||
if ((fromUnit == toUnit) && (fromUnit == 'mg' || fromUnit == 'ug' || fromUnit == 'g')) {
|
||||
return amount;
|
||||
}
|
||||
|
||||
// IU -> ug for Vitamin D
|
||||
if (fromUnit == 'iu' && isVitaminD) {
|
||||
// 1 µg = 40 IU => ug = IU / 40
|
||||
final ug = amount / 40.0;
|
||||
if (toUnit == 'ug') return ug;
|
||||
if (toUnit == 'mg') return ug / 1000.0;
|
||||
if (toUnit == 'g') return ug / 1e6;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Mass conversions
|
||||
double? inUg;
|
||||
if (fromUnit == 'ug') {
|
||||
inUg = amount;
|
||||
} else if (fromUnit == 'mg') {
|
||||
inUg = amount * 1000.0;
|
||||
} else if (fromUnit == 'g') {
|
||||
inUg = amount * 1e6;
|
||||
} else {
|
||||
// Unsupported unit (e.g., ml, drops)
|
||||
return null;
|
||||
}
|
||||
|
||||
if (toUnit == 'ug') return inUg;
|
||||
if (toUnit == 'mg') return inUg / 1000.0;
|
||||
if (toUnit == 'g') return inUg / 1e6;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normalize an ingredient name for alias matching
|
||||
String _normalizeIngredientName(String name) {
|
||||
final lower = name.trim().toLowerCase();
|
||||
// Replace common punctuation with spaces, then condense
|
||||
final replaced = lower
|
||||
.replaceAll(RegExp(r'[\(\)\[\]\{\},;:+/_-]+'), ' ')
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
return replaced;
|
||||
}
|
||||
|
||||
// -----------------------
|
||||
// Life stage matching
|
||||
// -----------------------
|
||||
|
||||
LifeStage? _matchLifeStageForProfile(List<LifeStage> stages, _UserProfile profile) {
|
||||
// Exclude pregnancy/lactation when we don't track that state yet
|
||||
final filtered = stages.where((s) {
|
||||
final l = s.ageRange.toLowerCase();
|
||||
return !(l.contains('pregnancy') || l.contains('lactation'));
|
||||
}).toList();
|
||||
|
||||
// Try in order:
|
||||
// 1) Exact age match + exact sex
|
||||
final exactSexMatch = filtered.where((s) => _matchesAge(s.ageRange, profile) && _matchesSex(s.sex, profile)).toList();
|
||||
if (exactSexMatch.isNotEmpty) return exactSexMatch.first;
|
||||
|
||||
// 2) Age match + sex == 'both'
|
||||
final bothMatch = filtered.where((s) => _matchesAge(s.ageRange, profile) && s.sex.toLowerCase() == 'both').toList();
|
||||
if (bothMatch.isNotEmpty) return bothMatch.first;
|
||||
|
||||
// 3) Age match ignoring sex (fallback)
|
||||
final ageOnly = filtered.where((s) => _matchesAge(s.ageRange, profile)).toList();
|
||||
if (ageOnly.isNotEmpty) return ageOnly.first;
|
||||
|
||||
// 4) If nothing matches, try 'adult-like' fallback: pick a reasonable adult range
|
||||
final adultFallback = filtered.where((s) => s.ageRange.contains('19-30') || s.ageRange.contains('31-50')).toList();
|
||||
if (adultFallback.isNotEmpty) return adultFallback.first;
|
||||
|
||||
// 5) Any entry as last resort
|
||||
return filtered.isNotEmpty ? filtered.first : null;
|
||||
}
|
||||
|
||||
bool _matchesSex(String stageSex, _UserProfile profile) {
|
||||
final s = stageSex.toLowerCase();
|
||||
if (s == 'both') return true;
|
||||
if (profile.isInfant && s == 'infant') return true;
|
||||
if (profile.sex == _Sex.male && s == 'male') return true;
|
||||
if (profile.sex == _Sex.female && s == 'female') return true;
|
||||
|
||||
// For 'Other' or 'Prefer not to say', accept 'both'
|
||||
if (profile.sex == _Sex.unknown && s == 'both') return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _matchesAge(String ageRange, _UserProfile profile) {
|
||||
final ar = ageRange.toLowerCase().trim();
|
||||
|
||||
// Common shorthand: "adult" (assume >= 18 years)
|
||||
if (ar == 'adult' || ar == 'adults') {
|
||||
return profile.ageYears >= 18;
|
||||
}
|
||||
|
||||
// Months range: e.g., "0-6 mo" or "7-12 mo"
|
||||
final moMatch = RegExp(r'^(\d+)\s*-\s*(\d+)\s*mo$').firstMatch(ar);
|
||||
if (moMatch != null) {
|
||||
if (!profile.isInfant) return false;
|
||||
final minMo = int.parse(moMatch.group(1)!);
|
||||
final maxMo = int.parse(moMatch.group(2)!);
|
||||
return profile.ageMonths >= minMo && profile.ageMonths <= maxMo;
|
||||
}
|
||||
|
||||
// Years range: e.g., "1-3 y", "4-8 y", "9-13 y", "14-18 y", "19-30 y", "31-50 y", "51-70 y"
|
||||
final yearRange = RegExp(r'^(\d+)\s*-\s*(\d+)\s*y$').firstMatch(ar);
|
||||
if (yearRange != null) {
|
||||
final minY = int.parse(yearRange.group(1)!);
|
||||
final maxY = int.parse(yearRange.group(2)!);
|
||||
return profile.ageYears >= minY && profile.ageYears <= maxY;
|
||||
}
|
||||
|
||||
// Greater than: e.g., ">70 y"
|
||||
final gtYear = RegExp(r'^>\s*(\d+)\s*y$').firstMatch(ar);
|
||||
if (gtYear != null) {
|
||||
final minExclusive = int.parse(gtYear.group(1)!);
|
||||
return profile.ageYears > minExclusive;
|
||||
}
|
||||
|
||||
// "infant" buckets handled via months range above
|
||||
// Any unknown format: do a best-effort fallback
|
||||
if (profile.isInfant) {
|
||||
// If the stage mentions "infant", accept
|
||||
if (ar.contains('infant')) return true;
|
||||
// Else, if it starts at 0-? y, let's accept if upper bound >= 0 (rare)
|
||||
if (ar.contains('0-') && ar.contains('y')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// If we are adult and stage is one of the adult ranges not following the patterns,
|
||||
// just return false to avoid false positives.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal representation of user profile for matching
|
||||
class _UserProfile {
|
||||
final int ageYears; // rounded down, e.g., 29
|
||||
final int ageMonths; // total months (for infants). If >= 12, consider non-infant.
|
||||
final _Sex sex;
|
||||
bool get isInfant => ageYears < 1;
|
||||
|
||||
_UserProfile({
|
||||
required this.ageYears,
|
||||
required this.ageMonths,
|
||||
required this.sex,
|
||||
});
|
||||
|
||||
factory _UserProfile.from({DateTime? dateOfBirth, String? gender}) {
|
||||
final now = DateTime.now();
|
||||
|
||||
int years;
|
||||
int monthsTotal;
|
||||
if (dateOfBirth == null) {
|
||||
// Default to adult 30 years old when unknown
|
||||
years = 30;
|
||||
monthsTotal = 30 * 12;
|
||||
} else {
|
||||
years = now.year - dateOfBirth.year;
|
||||
final beforeBirthday = (now.month < dateOfBirth.month) ||
|
||||
(now.month == dateOfBirth.month && now.day < dateOfBirth.day);
|
||||
if (beforeBirthday) years = max(0, years - 1);
|
||||
|
||||
// Calculate total months difference
|
||||
int months = (now.year - dateOfBirth.year) * 12 + (now.month - dateOfBirth.month);
|
||||
if (now.day < dateOfBirth.day) {
|
||||
months = max(0, months - 1);
|
||||
}
|
||||
monthsTotal = max(0, months);
|
||||
}
|
||||
|
||||
final s = _parseSex(gender);
|
||||
|
||||
return _UserProfile(
|
||||
ageYears: years,
|
||||
ageMonths: monthsTotal,
|
||||
sex: s,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _Sex { male, female, unknown }
|
||||
|
||||
_Sex _parseSex(String? gender) {
|
||||
if (gender == null) return _Sex.unknown;
|
||||
final g = gender.trim().toLowerCase();
|
||||
if (g == 'male') return _Sex.male;
|
||||
if (g == 'female') return _Sex.female;
|
||||
return _Sex.unknown; // 'Other', 'Prefer not to say' -> unknown
|
||||
}
|
359
lib/services/simple_notification_service.dart
Normal file
359
lib/services/simple_notification_service.dart
Normal file
@@ -0,0 +1,359 @@
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:supplements/logging.dart';
|
||||
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 '../models/supplement.dart';
|
||||
|
||||
/// A minimal notification scheduler focused purely on:
|
||||
/// - Initialization
|
||||
/// - Permission requests
|
||||
/// - Scheduling daily notifications for supplements
|
||||
/// - Canceling scheduled notifications
|
||||
///
|
||||
/// No retries, no snooze, no database logic.
|
||||
class SimpleNotificationService {
|
||||
SimpleNotificationService._internal();
|
||||
static final SimpleNotificationService instance =
|
||||
SimpleNotificationService._internal();
|
||||
|
||||
final FlutterLocalNotificationsPlugin _plugin =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
|
||||
bool _initialized = false;
|
||||
|
||||
// Channel IDs
|
||||
static const String _channelDailyId = 'supplement_reminders';
|
||||
static const String _channelDailyName = 'Supplement Reminders';
|
||||
static const String _channelDailyDescription = 'Daily supplement intake reminders';
|
||||
|
||||
/// Initialize timezone data and the notifications plugin.
|
||||
///
|
||||
/// Note: This does not request runtime permissions. Call [requestPermissions]
|
||||
/// to prompt the user for notification permissions.
|
||||
Future<void> initialize() async {
|
||||
if (_initialized) return;
|
||||
|
||||
// Initialize timezone database and set a sane default.
|
||||
// If you prefer, replace 'Europe/Amsterdam' with your preferred default,
|
||||
// or integrate a platform timezone resolver.
|
||||
tzdata.initializeTimeZones();
|
||||
tz.setLocalLocation(tz.getLocation('Europe/Amsterdam'));
|
||||
|
||||
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
final iosSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: false,
|
||||
requestBadgePermission: false,
|
||||
requestSoundPermission: false,
|
||||
notificationCategories: [
|
||||
DarwinNotificationCategory(
|
||||
'single',
|
||||
actions: [
|
||||
DarwinNotificationAction.plain('take_single', 'Take'),
|
||||
],
|
||||
),
|
||||
DarwinNotificationCategory(
|
||||
'group',
|
||||
actions: [
|
||||
DarwinNotificationAction.plain('take_group', 'Take All'),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
const linuxSettings = LinuxInitializationSettings(
|
||||
defaultActionName: 'Open notification',
|
||||
);
|
||||
|
||||
final initSettings = InitializationSettings(
|
||||
android: androidSettings,
|
||||
iOS: iosSettings,
|
||||
linux: linuxSettings,
|
||||
);
|
||||
|
||||
await _plugin.initialize(
|
||||
initSettings,
|
||||
onDidReceiveNotificationResponse: (response) {
|
||||
NotificationRouter.instance.handleNotificationResponse(response);
|
||||
},
|
||||
);
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// Request runtime notification permissions.
|
||||
///
|
||||
/// On Android 13+, this will prompt for POST_NOTIFICATIONS. On older Android,
|
||||
/// this is a no-op. On iOS, it requests alert/badge/sound.
|
||||
Future<bool> requestPermissions() async {
|
||||
// Ensure the plugin is ready before requesting permissions.
|
||||
if (!_initialized) {
|
||||
await initialize();
|
||||
}
|
||||
|
||||
bool granted = true;
|
||||
|
||||
final androidPlugin = _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>();
|
||||
if (androidPlugin != null) {
|
||||
final ok = await androidPlugin.requestNotificationsPermission();
|
||||
granted = granted && (ok == true);
|
||||
}
|
||||
|
||||
final iosPlugin = _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin>();
|
||||
if (iosPlugin != null) {
|
||||
final ok = await iosPlugin.requestPermissions(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
);
|
||||
granted = granted && (ok == true);
|
||||
}
|
||||
|
||||
return granted;
|
||||
}
|
||||
|
||||
/// Schedule grouped daily reminders for a list of supplements.
|
||||
///
|
||||
/// - Groups supplements by HH:mm and schedules one notification per time slot.
|
||||
/// - Uses daily recurrence via matchDateTimeComponents: DateTimeComponents.time.
|
||||
/// - Keeps iOS pending notifications well below the 64 limit.
|
||||
///
|
||||
/// IDs:
|
||||
/// - Group ID per time slot: 40000 + hour*60 + minute.
|
||||
/// - Stable and predictable for cancel/update operations.
|
||||
Future<void> scheduleDailyGroupedReminders(List<Supplement> supplements) async {
|
||||
if (!_initialized) {
|
||||
await initialize();
|
||||
}
|
||||
|
||||
printLog('🛠 scheduleDailyGroupedReminders -> ${supplements.length} supplements');
|
||||
|
||||
// Clear everything first to avoid duplicates or stale schedules
|
||||
await cancelAll();
|
||||
printLog('🧹 Cleared all existing notifications before scheduling groups');
|
||||
|
||||
// Build groups: HH:mm -> list<Supplement>
|
||||
final Map<String, List<Supplement>> groups = {};
|
||||
for (final s in supplements.where((s) => s.isActive && s.reminderTimes.isNotEmpty && s.id != null)) {
|
||||
for (final timeStr in s.reminderTimes) {
|
||||
final parts = timeStr.split(':');
|
||||
if (parts.length != 2) continue;
|
||||
final hour = int.tryParse(parts[0]);
|
||||
final minute = int.tryParse(parts[1]);
|
||||
if (hour == null || minute == null) continue;
|
||||
|
||||
final key = '${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}';
|
||||
groups.putIfAbsent(key, () => []).add(s);
|
||||
}
|
||||
}
|
||||
|
||||
printLog('⏱ Found ${groups.length} time group(s): ${groups.keys.toList()}');
|
||||
|
||||
if (groups.isEmpty) {
|
||||
printLog('⚠️ No groups to schedule (no active supplements with reminder times)');
|
||||
return;
|
||||
}
|
||||
|
||||
// Schedule one notification per time group
|
||||
for (final entry in groups.entries) {
|
||||
final timeKey = entry.key; // HH:mm
|
||||
final items = entry.value;
|
||||
|
||||
final parts = timeKey.split(':');
|
||||
final hour = int.parse(parts[0]);
|
||||
final minute = int.parse(parts[1]);
|
||||
|
||||
final when = _nextInstanceOfTime(hour, minute);
|
||||
final id = 40000 + (hour * 60) + minute;
|
||||
|
||||
final count = items.length;
|
||||
final title = count == 1
|
||||
? 'Time for ${items.first.name}'
|
||||
: 'Time for $count supplements';
|
||||
|
||||
// Build body that lists each supplement concisely
|
||||
final bodyLines = items.map((s) {
|
||||
final units = s.numberOfUnits;
|
||||
final unitType = s.unitType;
|
||||
final perUnit = s.ingredientsPerUnit;
|
||||
return '${s.name} — $units $unitType (${perUnit})';
|
||||
}).toList();
|
||||
final body = bodyLines.join('\n');
|
||||
|
||||
printLog('📅 Scheduling group $timeKey (count=$count) id=$id');
|
||||
printLog('🕒 Now=${tz.TZDateTime.now(tz.local)} | When=$when');
|
||||
|
||||
// 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});
|
||||
|
||||
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,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final iosDetails = DarwinNotificationDetails(
|
||||
categoryIdentifier: isSingle ? 'single' : 'group',
|
||||
);
|
||||
|
||||
await _plugin.zonedSchedule(
|
||||
id,
|
||||
title,
|
||||
isSingle ? body : 'Tap to see details',
|
||||
when,
|
||||
NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
),
|
||||
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
|
||||
matchDateTimeComponents: DateTimeComponents.time,
|
||||
payload: payloadStr,
|
||||
);
|
||||
|
||||
printLog('✅ Scheduled group $timeKey with ID $id');
|
||||
}
|
||||
|
||||
// Log what the system reports as pending
|
||||
try {
|
||||
final pending = await _plugin.pendingNotificationRequests();
|
||||
printLog('📋 Pending notifications after scheduling: ${pending.length}');
|
||||
for (final p in pending) {
|
||||
printLog(' - ID=${p.id}, Title=${p.title}, BodyLen=${p.body?.length ?? 0}');
|
||||
}
|
||||
} catch (e) {
|
||||
printLog('⚠️ Could not fetch pending notifications: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience to schedule grouped reminders for today and tomorrow.
|
||||
///
|
||||
/// For iOS’s 64 limit, we stick to one day for recurring (matchDateTimeComponents)
|
||||
/// which already repeats every day without needing to schedule future dates.
|
||||
/// If you want an extra safety net, you could schedule tomorrow’s one-offs,
|
||||
/// but with daily components this is unnecessary and risks hitting iOS limits.
|
||||
Future<void> scheduleDailyGroupedRemindersSafe(List<Supplement> supplements) async {
|
||||
// For now, just schedule today’s recurring groups.
|
||||
await scheduleDailyGroupedReminders(supplements);
|
||||
}
|
||||
|
||||
/// Cancel all scheduled reminders for a given [supplementId].
|
||||
///
|
||||
/// We assume up to 100 slots per supplement (00-99). This is simple and safe.
|
||||
Future<void> cancelSupplementReminders(int supplementId) async {
|
||||
if (!_initialized) {
|
||||
await initialize();
|
||||
}
|
||||
|
||||
for (int i = 0; i < 100; i++) {
|
||||
await _plugin.cancel(supplementId * 100 + i);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel all scheduled notifications.
|
||||
Future<void> cancelAll() async {
|
||||
if (!_initialized) {
|
||||
await initialize();
|
||||
}
|
||||
await _plugin.cancelAll();
|
||||
}
|
||||
|
||||
/// Show an immediate notification. Useful for quick diagnostics.
|
||||
Future<void> showInstant({
|
||||
required String title,
|
||||
required String body,
|
||||
String? payload,
|
||||
}) async {
|
||||
if (!_initialized) {
|
||||
await initialize();
|
||||
}
|
||||
|
||||
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(),
|
||||
),
|
||||
payload: payload,
|
||||
);
|
||||
}
|
||||
|
||||
Future<NotificationAppLaunchDetails?> getLaunchDetails() async {
|
||||
if (!_initialized) {
|
||||
await initialize();
|
||||
}
|
||||
try {
|
||||
final details = await _plugin.getNotificationAppLaunchDetails();
|
||||
return details;
|
||||
} catch (e) {
|
||||
printLog('⚠️ getLaunchDetails error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to compute the next instance of [hour]:[minute] in the local tz.
|
||||
tz.TZDateTime _nextInstanceOfTime(int hour, int minute) {
|
||||
final now = tz.TZDateTime.now(tz.local);
|
||||
var scheduled =
|
||||
tz.TZDateTime(tz.local, now.year, now.month, now.day, hour, minute);
|
||||
|
||||
if (scheduled.isBefore(now)) {
|
||||
scheduled = scheduled.add(const Duration(days: 1));
|
||||
printLog('⏭ Scheduling for tomorrow at ${scheduled.toString()} (${scheduled.timeZoneName})');
|
||||
} else {
|
||||
printLog('⏲ Scheduling for today at ${scheduled.toString()} (${scheduled.timeZoneName})');
|
||||
}
|
||||
return scheduled;
|
||||
}
|
||||
|
||||
/// Debug helper to fetch and log all pending notifications.
|
||||
Future<List<PendingNotificationRequest>> getPendingNotifications() async {
|
||||
if (!_initialized) {
|
||||
await initialize();
|
||||
}
|
||||
final list = await _plugin.pendingNotificationRequests();
|
||||
printLog('🧾 getPendingNotifications -> ${list.length} pending');
|
||||
for (final p in list) {
|
||||
printLog(' • ID=${p.id}, Title=${p.title}, Payload=${p.payload}');
|
||||
}
|
||||
return list;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user