10 Commits

Author SHA1 Message Date
62debb6a7c chore: bump version to 1.0.8+30082025 2025-08-30 01:53:58 +02:00
f7966ce587 feat: Implement snooze functionality for notifications
- Added snooze duration setting in SettingsScreen.
- Created DebugNotificationsScreen to view pending notifications and logs.
- Integrated notification logging with NotificationDebugStore.
- Enhanced SimpleNotificationService to handle snooze actions and log notifications.
- Removed ProfileSetupScreen as it is no longer needed.
- Updated NotificationRouter to manage snooze actions without UI.
- Refactored settings provider to include snooze duration management.
2025-08-30 01:51:38 +02:00
811c1f3d6a chore: bump version to 1.0.7+30082025 2025-08-30 01:03:22 +02:00
6dccac6124 notification overhaul 2025-08-30 00:12:29 +02:00
9ae2bb5654 chore: bump version to 1.0.6+28082025 2025-08-28 15:29:36 +02:00
31e04fe260 feat: adds RDA for intake of vitamins and certain elements based on
canada health values
2025-08-28 15:29:20 +02:00
6524e625d8 bugfix: retry notification should now only show up if you did NOT yet
take the supplement on that day.
2025-08-28 11:44:39 +02:00
142359bf94 bugfix: changing times for supplements now still allows for proper
syncing
2025-08-28 11:44:14 +02:00
731ac1567d chore: bump version to 1.0.5+27082025 2025-08-27 21:50:12 +02:00
31e1b4f0bb fix: ui cleanup 2025-08-27 21:49:30 +02:00
35 changed files with 2778 additions and 2430 deletions

9
.zed/debug.json Normal file
View File

@@ -0,0 +1,9 @@
[
{
"label": "Debug Flutter App",
"adapter": "Dart",
"type": "flutter",
"program": "lib/main.dart",
"args": ["--web-port=9090"]
}
]

View File

@@ -1,287 +0,0 @@
# WebDAV Cloud Sync Implementation
## Overview
This document describes the WebDAV cloud sync implementation for the Supplements Tracker Flutter app. The implementation allows users to synchronize their supplement data across multiple devices using WebDAV-compatible servers like Nextcloud, ownCloud, or any standard WebDAV server.
## Architecture
### Core Components
#### 1. Data Models Enhanced with Sync Metadata
All core data models (`Supplement`, `SupplementIntake`, `Ingredient`) have been enhanced with sync metadata:
- `syncId: String` - Unique identifier for sync operations (UUID)
- `lastModified: DateTime` - Timestamp of last modification
- `syncStatus: SyncStatus` - Current sync state (pending, synced, modified, conflict, etc.)
- `isDeleted: bool` - Soft delete flag for sync purposes
#### 2. Sync Enumerations (`lib/models/sync_enums.dart`)
- `SyncStatus` - Track individual record sync states
- `SyncOperationStatus` - Overall sync operation states
- `ConflictResolutionStrategy` - How to handle conflicts
- `SyncFrequency` - Auto-sync timing options
- `ConflictType` - Types of sync conflicts
#### 3. Sync Data Models (`lib/models/sync_data.dart`)
- `SyncData` - Complete data structure for WebDAV JSON format
- `SyncConflict` - Represents conflicts between local and remote data
- `SyncResult` - Results and statistics from sync operations
- `SyncStatistics` - Detailed sync operation metrics
#### 4. WebDAV Sync Service (`lib/services/webdav_sync_service.dart`)
Core service handling WebDAV communication:
- Server configuration and authentication
- Data upload/download operations
- Conflict detection and basic resolution
- Network connectivity checking
- Device identification
#### 5. Sync Provider (`lib/providers/sync_provider.dart`)
State management layer for sync operations:
- Manages sync status and progress
- Handles user configuration
- Coordinates between WebDAV service and UI
- Manages conflict resolution workflow
#### 6. UI Components
- `SyncSettingsScreen` - Complete WebDAV configuration interface
- Integration with existing settings screen
- Real-time sync status indicators
- Conflict resolution dialogs
### Database Schema Changes
The database has been upgraded to version 6 with sync support:
```sql
-- New sync columns added to existing tables
ALTER TABLE supplements ADD COLUMN syncId TEXT NOT NULL UNIQUE;
ALTER TABLE supplements ADD COLUMN lastModified TEXT NOT NULL;
ALTER TABLE supplements ADD COLUMN syncStatus TEXT NOT NULL DEFAULT 'pending';
ALTER TABLE supplements ADD COLUMN isDeleted INTEGER NOT NULL DEFAULT 0;
-- Similar columns added to supplement_intakes table
-- New sync metadata table
CREATE TABLE sync_metadata (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
value TEXT NOT NULL,
lastUpdated TEXT NOT NULL
);
-- Device info table for multi-device conflict resolution
CREATE TABLE device_info (
id INTEGER PRIMARY KEY AUTOINCREMENT,
deviceId TEXT NOT NULL UNIQUE,
deviceName TEXT NOT NULL,
lastSyncTime TEXT,
createdAt TEXT NOT NULL
);
```
## Sync Process Flow
### 1. Configuration Phase
1. User enters WebDAV server URL, username, and password/app password
2. System tests connection and validates credentials
3. Creates sync directory on server if needed
4. Stores encrypted credentials locally using `flutter_secure_storage`
### 2. Sync Operation
1. Check network connectivity
2. Download remote sync data (JSON file from WebDAV)
3. Compare with local data using timestamps and sync IDs
4. Detect conflicts (modification, deletion, creation conflicts)
5. Merge data according to conflict resolution strategy
6. Upload merged data back to server
7. Update local sync status
### 3. Conflict Resolution
- **Last-write-wins**: Prefer most recently modified record
- **Prefer Local**: Always keep local changes
- **Prefer Remote**: Always keep remote changes
- **Manual**: Present conflicts to user for individual resolution
## Security Considerations
### Data Protection
- Credentials stored using `flutter_secure_storage` with platform encryption
- Support for app passwords instead of main account passwords
- No sensitive data logged in release builds
### Network Security
- HTTPS enforced for WebDAV connections
- Connection testing before storing credentials
- Proper error handling for authentication failures
## Supported WebDAV Servers
### Tested Compatibility
- **Nextcloud** - Full compatibility with automatic path detection
- **ownCloud** - Full compatibility with automatic path detection
- **Generic WebDAV** - Manual path configuration required
### Server Requirements
- WebDAV protocol support
- File read/write permissions
- Directory creation permissions
- Basic or digest authentication
## Data Synchronization Format
### JSON Structure
```json
{
"version": 1,
"deviceId": "device_12345_67890",
"deviceName": "John's Phone",
"syncTimestamp": "2024-01-15T10:30:00.000Z",
"supplements": [
{
"syncId": "uuid-supplement-1",
"name": "Vitamin D3",
"ingredients": [...],
"lastModified": "2024-01-15T09:15:00.000Z",
"syncStatus": "synced",
"isDeleted": false,
// ... other supplement fields
}
],
"intakes": [
{
"syncId": "uuid-intake-1",
"supplementId": 1,
"takenAt": "2024-01-15T08:00:00.000Z",
"lastModified": "2024-01-15T08:00:30.000Z",
"syncStatus": "synced",
"isDeleted": false,
// ... other intake fields
}
],
"metadata": {
"totalSupplements": 5,
"totalIntakes": 150
}
}
```
## Usage Instructions
### Initial Setup
1. Navigate to Settings → Cloud Sync
2. Enter your WebDAV server details:
- Server URL (e.g., `https://cloud.example.com`)
- Username
- Password or app password
- Optional device name
3. Test connection
4. Configure sync preferences
### Sync Options
- **Manual Sync**: Sync on demand via button press
- **Auto Sync**: Automatic background sync at configured intervals
- Every 15 minutes
- Hourly
- Every 6 hours
- Daily
### Conflict Resolution
- Configure preferred resolution strategy in settings
- Review individual conflicts when they occur
- Auto-resolve based on chosen strategy
## Implementation Status
### ✅ Completed (Phase 1)
- Core sync architecture and data models
- WebDAV service implementation
- Database schema with sync support
- Basic UI for configuration and management
- Manual sync operations
- Conflict detection
- Secure credential storage
### 🔄 Future Enhancements (Phase 2+)
- Background sync scheduling
- Advanced conflict resolution UI
- Sync history and logs
- Client-side encryption option
- Multiple server support
- Bandwidth optimization
- Offline queue management
## Dependencies Added
```yaml
dependencies:
webdav_client: ^1.2.2 # WebDAV protocol client
connectivity_plus: ^6.0.5 # Network connectivity checking
flutter_secure_storage: ^9.2.2 # Secure credential storage
uuid: ^4.5.0 # UUID generation for sync IDs
crypto: ^3.0.5 # Data integrity verification
```
## File Structure
```
lib/
├── models/
│ ├── sync_enums.dart # Sync-related enumerations
│ ├── sync_data.dart # Sync data models
│ ├── supplement.dart # Enhanced with sync metadata
│ ├── supplement_intake.dart # Enhanced with sync metadata
│ └── ingredient.dart # Enhanced with sync metadata
├── services/
│ ├── webdav_sync_service.dart # WebDAV communication
│ └── database_helper.dart # Enhanced with sync methods
├── providers/
│ └── sync_provider.dart # Sync state management
└── screens/
└── sync_settings_screen.dart # Sync configuration UI
```
## Error Handling
The implementation includes comprehensive error handling for:
- Network connectivity issues
- Authentication failures
- Server unavailability
- Malformed sync data
- Storage permission issues
- Conflicting concurrent modifications
## Testing Recommendations
### Unit Tests
- Sync data serialization/deserialization
- Conflict detection algorithms
- Merge logic validation
### Integration Tests
- WebDAV server communication
- Database sync operations
- Cross-device sync scenarios
### User Testing
- Multi-device sync workflows
- Network interruption recovery
- Large dataset synchronization
- Conflict resolution user experience
## Performance Considerations
- Incremental sync (only changed data)
- Compression for large datasets
- Connection timeout handling
- Memory-efficient JSON processing
- Background processing for large operations
This implementation provides a solid foundation for cloud synchronization while maintaining data integrity and user experience across multiple devices.

View File

@@ -4,12 +4,20 @@
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" /> <uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" /> <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<application <application
android:label="supplements" android:label="My Supplements"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:enableOnBackInvokedCallback="true"> android:enableOnBackInvokedCallback="true">
<service android:name="com.gdelataillade.alarm.services.NotificationOnKillService" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

71
lib/data/rda_data.dart Normal file
View File

@@ -0,0 +1,71 @@
// Recommended Daily Allowances (RDA) for common vitamins and minerals.
// All values are in milligrams (mg) unless otherwise specified.
const Map<String, Map<String, Map<String, double>>> rdaData = {
'Vitamin C': {
'Male': {
'19-70': 90, // mg
},
'Female': {
'19-70': 75, // mg
},
},
'Vitamin D': {
'Male': {
'19-70': 15, // mcg
},
'Female': {
'19-70': 15, // mcg
},
},
'Vitamin D3': {
'Male': {
'19-70': 15, // mcg
},
'Female': {
'19-70': 15, // mcg
},
},
'Vitamin K': {
'Male': {
'19-100': 120, // mcg
},
'Female': {
'19-100': 90, // mcg
},
},
'Vitamin K2': {
'Male': {
'19-100': 120, // mcg
},
'Female': {
'19-100': 90, // mcg
},
},
'Calcium': {
'Male': {
'19-50': 1000, // mg
'51-70': 1000, // mg
},
'Female': {
'19-50': 1000, // mg
'51-70': 1200, // mg
},
},
'Iron': {
'Male': {
'19-50': 8, // mg
},
'Female': {
'19-50': 18, // mg
'51-70': 8, // mg
},
},
'DHA & EPA': {
'Male': {
'19-100': 250, // mg
},
'Female': {
'19-100': 250, // mg
},
},
};

8
lib/logging.dart Normal file
View File

@@ -0,0 +1,8 @@
// A simple logging function that prints a message to the console if [kDebugMode] is enabled.
import 'package:flutter/foundation.dart';
void printLog(String message) {
if (kDebugMode) {
print('SupplementsLog: $message');
}
}

View File

@@ -1,18 +1,47 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; // Import this
import 'package:provider/provider.dart';
import 'package:supplements/logging.dart';
import 'providers/settings_provider.dart'; import 'providers/settings_provider.dart';
import 'providers/supplement_provider.dart';
import 'providers/simple_sync_provider.dart'; import 'providers/simple_sync_provider.dart';
import 'providers/supplement_provider.dart';
import 'screens/home_screen.dart'; import 'screens/home_screen.dart';
import 'services/notification_router.dart';
import 'services/simple_notification_service.dart';
void main() { final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
runApp(const MyApp());
// Top-level function to handle notification responses in the background
@pragma('vm:entry-point')
void notificationTapBackground(NotificationResponse notificationResponse) {
// handle action here
printLog('Background notification action tapped: ${notificationResponse.actionId}');
NotificationRouter.instance.handleNotificationResponse(notificationResponse);
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize SimpleNotificationService early
await SimpleNotificationService.instance.initialize(
onDidReceiveBackgroundNotificationResponse: notificationTapBackground,
);
final settingsProvider = SettingsProvider();
await settingsProvider.initialize();
runApp(MyApp(settingsProvider: settingsProvider));
} }
class MyApp extends StatelessWidget { class MyApp extends StatelessWidget {
const MyApp({super.key}); final SettingsProvider settingsProvider;
const MyApp({super.key, required this.settingsProvider});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -21,8 +50,8 @@ class MyApp extends StatelessWidget {
ChangeNotifierProvider( ChangeNotifierProvider(
create: (context) => SupplementProvider()..initialize(), create: (context) => SupplementProvider()..initialize(),
), ),
ChangeNotifierProvider( ChangeNotifierProvider.value(
create: (context) => SettingsProvider()..initialize(), value: settingsProvider,
), ),
ChangeNotifierProvider( ChangeNotifierProvider(
create: (context) => SimpleSyncProvider(), create: (context) => SimpleSyncProvider(),
@@ -35,15 +64,25 @@ class MyApp extends StatelessWidget {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
final supplementProvider = context.read<SupplementProvider>(); final supplementProvider = context.read<SupplementProvider>();
// Initialize notification router with the app's navigator
// This is done here because navigatorKey is only available after MaterialApp is built
NotificationRouter.instance.initialize(navigatorKey);
// If the app was launched via a notification, route to the proper dialog
// This needs to be called after the router is initialized with the navigatorKey
SimpleNotificationService.instance.getLaunchDetails().then((details) {
NotificationRouter.instance.handleAppLaunchDetails(details);
});
// Set up sync completion callback // Set up sync completion callback
syncProvider.setOnSyncCompleteCallback(() async { syncProvider.setOnSyncCompleteCallback(() async {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Sync completed, refreshing UI data...'); printLog('Sync completed, refreshing UI data...');
} }
await supplementProvider.loadSupplements(); await supplementProvider.loadSupplements();
await supplementProvider.loadTodayIntakes(); await supplementProvider.loadTodayIntakes();
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: UI data refreshed after sync'); printLog('UI data refreshed after sync');
} }
}); });
@@ -57,6 +96,7 @@ class MyApp extends StatelessWidget {
}); });
return MaterialApp( return MaterialApp(
navigatorKey: navigatorKey,
title: 'Supplements Tracker', title: 'Supplements Tracker',
theme: ThemeData( theme: ThemeData(
colorScheme: ColorScheme.fromSeed( colorScheme: ColorScheme.fromSeed(

View File

@@ -2,8 +2,8 @@ import 'dart:convert';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import 'ingredient.dart';
import '../services/database_sync_service.dart'; import '../services/database_sync_service.dart';
import 'ingredient.dart';
class Supplement { class Supplement {
final int? id; final int? id;
@@ -69,8 +69,7 @@ class Supplement {
} }
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { final map = <String, dynamic>{
'id': id,
'name': name, 'name': name,
'brand': brand, 'brand': brand,
'ingredients': jsonEncode(ingredients.map((ingredient) => ingredient.toMap()).toList()), 'ingredients': jsonEncode(ingredients.map((ingredient) => ingredient.toMap()).toList()),
@@ -86,6 +85,12 @@ class Supplement {
'syncStatus': syncStatus.name, 'syncStatus': syncStatus.name,
'isDeleted': isDeleted ? 1 : 0, 'isDeleted': isDeleted ? 1 : 0,
}; };
if (id != null) {
map['id'] = id;
}
return map;
} }
factory Supplement.fromMap(Map<String, dynamic> map) { factory Supplement.fromMap(Map<String, dynamic> map) {
@@ -133,6 +138,7 @@ class Supplement {
Supplement copyWith({ Supplement copyWith({
int? id, int? id,
bool setNullId = false,
String? name, String? name,
String? brand, String? brand,
List<Ingredient>? ingredients, List<Ingredient>? ingredients,
@@ -144,12 +150,13 @@ class Supplement {
DateTime? createdAt, DateTime? createdAt,
bool? isActive, bool? isActive,
String? syncId, String? syncId,
bool newSyncId = false,
DateTime? lastModified, DateTime? lastModified,
RecordSyncStatus? syncStatus, RecordSyncStatus? syncStatus,
bool? isDeleted, bool? isDeleted,
}) { }) {
return Supplement( return Supplement(
id: id ?? this.id, id: setNullId ? null : (id ?? this.id),
name: name ?? this.name, name: name ?? this.name,
brand: brand ?? this.brand, brand: brand ?? this.brand,
ingredients: ingredients ?? this.ingredients, ingredients: ingredients ?? this.ingredients,
@@ -160,7 +167,7 @@ class Supplement {
notes: notes ?? this.notes, notes: notes ?? this.notes,
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
isActive: isActive ?? this.isActive, isActive: isActive ?? this.isActive,
syncId: syncId ?? this.syncId, syncId: newSyncId ? null : (syncId ?? this.syncId),
lastModified: lastModified ?? this.lastModified, lastModified: lastModified ?? this.lastModified,
syncStatus: syncStatus ?? this.syncStatus, syncStatus: syncStatus ?? this.syncStatus,
isDeleted: isDeleted ?? this.isDeleted, isDeleted: isDeleted ?? this.isDeleted,

View File

@@ -10,6 +10,10 @@ enum ThemeOption {
class SettingsProvider extends ChangeNotifier { class SettingsProvider extends ChangeNotifier {
ThemeOption _themeOption = ThemeOption.system; ThemeOption _themeOption = ThemeOption.system;
// Profile fields
DateTime? _dateOfBirth;
String? _gender;
// Time range settings (stored as hours, 0-23) // Time range settings (stored as hours, 0-23)
int _morningStart = 5; int _morningStart = 5;
int _morningEnd = 10; int _morningEnd = 10;
@@ -20,10 +24,8 @@ class SettingsProvider extends ChangeNotifier {
int _nightStart = 23; int _nightStart = 23;
int _nightEnd = 4; int _nightEnd = 4;
// Persistent reminder settings // Notifications
bool _persistentReminders = true; int _snoozeMinutes = 10;
int _reminderRetryInterval = 5; // minutes
int _maxRetryAttempts = 3;
// Auto-sync settings // Auto-sync settings
bool _autoSyncEnabled = false; bool _autoSyncEnabled = false;
@@ -41,10 +43,8 @@ class SettingsProvider extends ChangeNotifier {
int get nightStart => _nightStart; int get nightStart => _nightStart;
int get nightEnd => _nightEnd; int get nightEnd => _nightEnd;
// Persistent reminder getters // Notifications
bool get persistentReminders => _persistentReminders; int get snoozeMinutes => _snoozeMinutes;
int get reminderRetryInterval => _reminderRetryInterval;
int get maxRetryAttempts => _maxRetryAttempts;
// Auto-sync getters // Auto-sync getters
bool get autoSyncEnabled => _autoSyncEnabled; bool get autoSyncEnabled => _autoSyncEnabled;
@@ -76,6 +76,13 @@ class SettingsProvider extends ChangeNotifier {
final themeIndex = prefs.getInt('theme_option') ?? 0; final themeIndex = prefs.getInt('theme_option') ?? 0;
_themeOption = ThemeOption.values[themeIndex]; _themeOption = ThemeOption.values[themeIndex];
// Load profile fields
final dobString = prefs.getString('date_of_birth');
if (dobString != null) {
_dateOfBirth = DateTime.tryParse(dobString);
}
_gender = prefs.getString('gender');
// Load time range settings // Load time range settings
_morningStart = prefs.getInt('morning_start') ?? 5; _morningStart = prefs.getInt('morning_start') ?? 5;
_morningEnd = prefs.getInt('morning_end') ?? 10; _morningEnd = prefs.getInt('morning_end') ?? 10;
@@ -86,10 +93,8 @@ class SettingsProvider extends ChangeNotifier {
_nightStart = prefs.getInt('night_start') ?? 23; _nightStart = prefs.getInt('night_start') ?? 23;
_nightEnd = prefs.getInt('night_end') ?? 4; _nightEnd = prefs.getInt('night_end') ?? 4;
// Load persistent reminder settings // Load snooze setting
_persistentReminders = prefs.getBool('persistent_reminders') ?? true; _snoozeMinutes = prefs.getInt('snooze_minutes') ?? 10;
_reminderRetryInterval = prefs.getInt('reminder_retry_interval') ?? 5;
_maxRetryAttempts = prefs.getInt('max_retry_attempts') ?? 3;
// Load auto-sync settings // Load auto-sync settings
_autoSyncEnabled = prefs.getBool('auto_sync_enabled') ?? false; _autoSyncEnabled = prefs.getBool('auto_sync_enabled') ?? false;
@@ -106,6 +111,16 @@ class SettingsProvider extends ChangeNotifier {
await prefs.setInt('theme_option', option.index); await prefs.setInt('theme_option', option.index);
} }
Future<void> setDateOfBirthAndGender(DateTime dateOfBirth, String gender) async {
_dateOfBirth = dateOfBirth;
_gender = gender;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setString('date_of_birth', dateOfBirth.toIso8601String());
await prefs.setString('gender', gender);
}
Future<void> setTimeRanges({ Future<void> setTimeRanges({
required int morningStart, required int morningStart,
required int morningEnd, required int morningEnd,
@@ -244,29 +259,17 @@ class SettingsProvider extends ChangeNotifier {
} }
} }
// Persistent reminder setters // Notifications setters
Future<void> setPersistentReminders(bool enabled) async { Future<void> setSnoozeMinutes(int minutes) async {
_persistentReminders = enabled; const allowed = [5, 10, 15, 20];
if (!allowed.contains(minutes)) {
throw ArgumentError('Snooze minutes must be one of ${allowed.join(", ")}');
}
_snoozeMinutes = minutes;
notifyListeners(); notifyListeners();
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool('persistent_reminders', enabled); await prefs.setInt('snooze_minutes', minutes);
}
Future<void> setReminderRetryInterval(int minutes) async {
_reminderRetryInterval = minutes;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('reminder_retry_interval', minutes);
}
Future<void> setMaxRetryAttempts(int attempts) async {
_maxRetryAttempts = attempts;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('max_retry_attempts', attempts);
} }
// Auto-sync setters // Auto-sync setters

View File

@@ -1,4 +1,5 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:supplements/logging.dart';
import '../services/database_sync_service.dart'; import '../services/database_sync_service.dart';
import '../services/auto_sync_service.dart'; import '../services/auto_sync_service.dart';
@@ -66,7 +67,7 @@ class SimpleSyncProvider with ChangeNotifier {
); );
if (kDebugMode) { if (kDebugMode) {
print('SimpleSyncProvider: Auto-sync service initialized'); printLog('SimpleSyncProvider: Auto-sync service initialized');
} }
} }
@@ -111,7 +112,7 @@ class SimpleSyncProvider with ChangeNotifier {
await _syncService.syncDatabase(); await _syncService.syncDatabase();
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Sync failed in provider: $e'); printLog('Sync failed in provider: $e');
} }
rethrow; rethrow;
} finally { } finally {

View File

@@ -3,21 +3,24 @@ import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:supplements/logging.dart';
import '../models/supplement.dart'; import '../models/supplement.dart';
import '../models/supplement_intake.dart'; import '../models/supplement_intake.dart';
import '../services/database_helper.dart'; import '../services/database_helper.dart';
import '../services/notification_service.dart'; import '../services/database_sync_service.dart';
import '../services/simple_notification_service.dart';
class SupplementProvider with ChangeNotifier, WidgetsBindingObserver { class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
final DatabaseHelper _databaseHelper = DatabaseHelper.instance; final DatabaseHelper _databaseHelper = DatabaseHelper.instance;
final NotificationService _notificationService = NotificationService(); final SimpleNotificationService _notificationService = SimpleNotificationService.instance;
bool _initialized = false;
List<Supplement> _supplements = []; List<Supplement> _supplements = [];
List<Map<String, dynamic>> _todayIntakes = []; List<Map<String, dynamic>> _todayIntakes = [];
List<Map<String, dynamic>> _monthlyIntakes = []; List<Map<String, dynamic>> _monthlyIntakes = [];
bool _isLoading = false; bool _isLoading = false;
Timer? _persistentReminderTimer;
Timer? _dateChangeTimer; Timer? _dateChangeTimer;
DateTime _lastDateCheck = DateTime.now(); DateTime _lastDateCheck = DateTime.now();
@@ -40,37 +43,22 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
} }
Future<void> initialize() async { Future<void> initialize() async {
if (_initialized) {
return;
}
_initialized = true;
// Add this provider as an observer for app lifecycle changes // Add this provider as an observer for app lifecycle changes
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
await _notificationService.initialize(); await _notificationService.initialize();
// Set up the callback for handling supplement intake from notifications
print('SupplementsLog: 📱 Setting up notification callback...');
_notificationService.setTakeSupplementCallback((supplementId, supplementName, units, unitType) {
print('SupplementsLog: 📱 === NOTIFICATION CALLBACK TRIGGERED ===');
print('SupplementsLog: 📱 Supplement ID: $supplementId');
print('SupplementsLog: 📱 Supplement Name: $supplementName');
print('SupplementsLog: 📱 Units: $units');
print('SupplementsLog: 📱 Unit Type: $unitType');
// Record the intake when user taps "Take" on notification
recordIntake(supplementId, 0.0, unitsTaken: units);
print('SupplementsLog: 📱 Intake recorded successfully');
print('SupplementsLog: 📱 === CALLBACK COMPLETE ===');
if (kDebugMode) {
print('SupplementsLog: 📱 Recorded intake from notification: $supplementName ($units $unitType)');
}
});
print('SupplementsLog: 📱 Notification callback setup complete');
// Request permissions with error handling // Request permissions with error handling
try { try {
await _notificationService.requestPermissions(); await _notificationService.requestPermissions();
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Error requesting notification permissions: $e'); printLog('Error requesting notification permissions: $e');
} }
// Continue without notifications rather than crashing // Continue without notifications rather than crashing
} }
@@ -78,35 +66,14 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
await loadSupplements(); await loadSupplements();
await loadTodayIntakes(); await loadTodayIntakes();
// Reschedule notifications for all active supplements to ensure persistence // Schedule notifications for all active supplements
await _rescheduleAllNotifications(); await _rescheduleAllNotifications();
// Start periodic checking for persistent reminders (every 5 minutes)
_startPersistentReminderCheck();
// Start date change monitoring to reset daily intake status // Start date change monitoring to reset daily intake status
_startDateChangeMonitoring(); _startDateChangeMonitoring();
} }
void _startPersistentReminderCheck() {
// Cancel any existing timer
_persistentReminderTimer?.cancel();
// Check every 5 minutes for persistent reminders
_persistentReminderTimer = Timer.periodic(const Duration(minutes: 5), (timer) async {
try {
// This will be called from settings provider context, so we need to import it
await _checkPersistentReminders();
} catch (e) {
if (kDebugMode) {
print('SupplementsLog: Error checking persistent reminders: $e');
}
}
});
// Also check immediately
_checkPersistentReminders();
}
void _startDateChangeMonitoring() { void _startDateChangeMonitoring() {
// Cancel any existing timer // Cancel any existing timer
@@ -120,8 +87,8 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
if (currentDate != lastCheckDate) { if (currentDate != lastCheckDate) {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Date changed detected: ${lastCheckDate} -> ${currentDate}'); printLog('Date changed detected: ${lastCheckDate} -> ${currentDate}');
print('SupplementsLog: Refreshing today\'s intakes for new day...'); printLog('Refreshing today\'s intakes for new day...');
} }
// Date has changed, refresh today's intakes // Date has changed, refresh today's intakes
@@ -129,49 +96,22 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
await loadTodayIntakes(); await loadTodayIntakes();
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Today\'s intakes refreshed for new day'); printLog('Today\'s intakes refreshed for new day');
} }
} }
}); });
} }
Future<void> _checkPersistentReminders() async {
// This method will be enhanced to accept settings from the UI layer
// For now, we'll check with default settings
// In practice, the UI should call checkPersistentRemindersWithSettings
if (kDebugMode) {
print('SupplementsLog: 📱 Checking persistent reminders with default settings');
}
}
// Method to be called from UI with actual settings
Future<void> checkPersistentRemindersWithSettings({
required bool persistentReminders,
required int reminderRetryInterval,
required int maxRetryAttempts,
}) async {
print('SupplementsLog: 📱 🔄 MANUAL CHECK: Persistent reminders called from UI');
await _notificationService.checkPersistentReminders(
persistentReminders,
reminderRetryInterval,
maxRetryAttempts,
);
}
// Add a manual trigger method for testing
Future<void> triggerRetryCheck() async {
print('SupplementsLog: 📱 🚨 MANUAL TRIGGER: Forcing retry check...');
await checkPersistentRemindersWithSettings(
persistentReminders: true,
reminderRetryInterval: 5, // Force 5 minute interval for testing
maxRetryAttempts: 3,
);
}
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
_persistentReminderTimer?.cancel();
_dateChangeTimer?.cancel(); _dateChangeTimer?.cancel();
super.dispose(); super.dispose();
} }
@@ -183,7 +123,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
if (state == AppLifecycleState.resumed) { if (state == AppLifecycleState.resumed) {
// App came back to foreground, check if date changed // App came back to foreground, check if date changed
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: App resumed, checking for date change...'); printLog('App resumed, checking for date change...');
} }
forceCheckDateChange(); forceCheckDateChange();
} }
@@ -191,23 +131,20 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
Future<void> _rescheduleAllNotifications() async { Future<void> _rescheduleAllNotifications() async {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: 📱 Rescheduling notifications for all active supplements...'); printLog('📱 Rescheduling notifications for all active supplements...');
} }
for (final supplement in _supplements) { try {
if (supplement.reminderTimes.isNotEmpty) { await _notificationService.scheduleDailyGroupedRemindersSafe(_supplements);
try { await _notificationService.getPendingNotifications();
await _notificationService.scheduleSupplementReminders(supplement); } catch (e) {
} catch (e) { if (kDebugMode) {
if (kDebugMode) { printLog('📱 Error scheduling grouped notifications: $e');
print('SupplementsLog: 📱 Error rescheduling notifications for ${supplement.name}: $e');
}
}
} }
} }
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: 📱 Finished rescheduling notifications'); printLog('📱 Finished rescheduling notifications');
} }
} }
@@ -216,16 +153,16 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
notifyListeners(); notifyListeners();
try { try {
print('SupplementsLog: Loading supplements from database...'); printLog('Loading supplements from database...');
_supplements = await _databaseHelper.getAllSupplements(); _supplements = await _databaseHelper.getAllSupplements();
print('SupplementsLog: Loaded ${_supplements.length} supplements'); printLog('Loaded ${_supplements.length} supplements');
for (var supplement in _supplements) { for (var supplement in _supplements) {
print('SupplementsLog: Supplement: ${supplement.name}'); printLog('Supplement: ${supplement.name}');
} }
} catch (e) { } catch (e) {
print('SupplementsLog: Error loading supplements: $e'); printLog('Error loading supplements: $e');
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Error loading supplements: $e'); printLog('Error loading supplements: $e');
} }
} finally { } finally {
_isLoading = false; _isLoading = false;
@@ -235,28 +172,23 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
Future<void> addSupplement(Supplement supplement) async { Future<void> addSupplement(Supplement supplement) async {
try { try {
print('SupplementsLog: Adding supplement: ${supplement.name}'); printLog('Adding supplement: ${supplement.name}');
final id = await _databaseHelper.insertSupplement(supplement); final id = await _databaseHelper.insertSupplement(supplement);
print('SupplementsLog: Supplement inserted with ID: $id'); printLog('Supplement inserted with ID: $id');
final newSupplement = supplement.copyWith(id: id); final newSupplement = supplement.copyWith(id: id);
// Schedule notifications (skip if there's an error) // Notifications will be rescheduled in grouped mode after reloading supplements
try {
await _notificationService.scheduleSupplementReminders(newSupplement);
print('SupplementsLog: Notifications scheduled');
} catch (notificationError) {
print('SupplementsLog: Warning: Could not schedule notifications: $notificationError');
}
await loadSupplements(); await loadSupplements();
print('SupplementsLog: Supplements reloaded, count: ${_supplements.length}'); printLog('Supplements reloaded, count: ${_supplements.length}');
await _rescheduleAllNotifications();
// Trigger sync after adding supplement // Trigger sync after adding supplement
_triggerSyncIfEnabled(); _triggerSyncIfEnabled();
} catch (e) { } catch (e) {
print('SupplementsLog: Error adding supplement: $e'); printLog('Error adding supplement: $e');
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Error adding supplement: $e'); printLog('Error adding supplement: $e');
} }
rethrow; rethrow;
} }
@@ -266,16 +198,36 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
try { try {
await _databaseHelper.updateSupplement(supplement); await _databaseHelper.updateSupplement(supplement);
// Reschedule notifications
await _notificationService.scheduleSupplementReminders(supplement);
await loadSupplements(); await loadSupplements();
await _rescheduleAllNotifications();
// Trigger sync after updating supplement // Trigger sync after updating supplement
_triggerSyncIfEnabled(); _triggerSyncIfEnabled();
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Error updating supplement: $e'); printLog('Error updating supplement: $e');
}
}
}
Future<void> duplicateSupplement(int supplementId) async {
try {
final originalSupplement = await _databaseHelper.getSupplement(supplementId);
if (originalSupplement != null) {
final newSupplement = originalSupplement.copyWith(
setNullId: true, // This will be a new entry
newSyncId: true, // Generate a new syncId
name: '${originalSupplement.name} (Copy)',
createdAt: DateTime.now(),
lastModified: DateTime.now(),
syncStatus: RecordSyncStatus.pending,
isDeleted: false,
);
await addSupplement(newSupplement);
}
} catch (e) {
if (kDebugMode) {
printLog('Error duplicating supplement: $e');
} }
} }
} }
@@ -284,16 +236,14 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
try { try {
await _databaseHelper.deleteSupplement(id); await _databaseHelper.deleteSupplement(id);
// Cancel notifications
await _notificationService.cancelSupplementReminders(id);
await loadSupplements(); await loadSupplements();
await _rescheduleAllNotifications();
// Trigger sync after deleting supplement // Trigger sync after deleting supplement
_triggerSyncIfEnabled(); _triggerSyncIfEnabled();
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Error deleting supplement: $e'); printLog('Error deleting supplement: $e');
} }
} }
} }
@@ -317,13 +267,13 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
// Show confirmation notification // Show confirmation notification
final supplement = _supplements.firstWhere((s) => s.id == supplementId); final supplement = _supplements.firstWhere((s) => s.id == supplementId);
final unitsText = unitsTaken != null && unitsTaken != 1 ? '${unitsTaken.toStringAsFixed(unitsTaken % 1 == 0 ? 0 : 1)} ${supplement.unitType}' : ''; final unitsText = unitsTaken != null && unitsTaken != 1 ? '${unitsTaken.toStringAsFixed(unitsTaken % 1 == 0 ? 0 : 1)} ${supplement.unitType}' : '';
await _notificationService.showInstantNotification( await _notificationService.showInstant(
'Supplement Taken', title: 'Supplement Taken',
'Recorded ${supplement.name}${unitsText.isNotEmpty ? ' - $unitsText' : ''} (${supplement.ingredientsDisplay})', body: 'Recorded ${supplement.name}${unitsText.isNotEmpty ? ' - $unitsText' : ''} (${supplement.ingredientsDisplay})',
); );
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Error recording intake: $e'); printLog('Error recording intake: $e');
} }
} }
} }
@@ -332,22 +282,22 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
try { try {
final today = DateTime.now(); final today = DateTime.now();
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Loading intakes for date: ${today.year}-${today.month}-${today.day}'); printLog('Loading intakes for date: ${today.year}-${today.month}-${today.day}');
} }
_todayIntakes = await _databaseHelper.getIntakesWithSupplementsForDate(today); _todayIntakes = await _databaseHelper.getIntakesWithSupplementsForDate(today);
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Loaded ${_todayIntakes.length} intakes for today'); printLog('Loaded ${_todayIntakes.length} intakes for today');
for (var intake in _todayIntakes) { for (var intake in _todayIntakes) {
print('SupplementsLog: - Supplement ID: ${intake['supplement_id']}, taken at: ${intake['takenAt']}'); printLog(' - Supplement ID: ${intake['supplement_id']}, taken at: ${intake['takenAt']}');
} }
} }
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Error loading today\'s intakes: $e'); printLog('Error loading today\'s intakes: $e');
} }
} }
} }
@@ -358,7 +308,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Error loading monthly intakes: $e'); printLog('Error loading monthly intakes: $e');
} }
} }
} }
@@ -368,7 +318,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
return await _databaseHelper.getIntakesWithSupplementsForDate(date); return await _databaseHelper.getIntakesWithSupplementsForDate(date);
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Error loading intakes for date: $e'); printLog('Error loading intakes for date: $e');
} }
return []; return [];
} }
@@ -388,7 +338,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
_triggerSyncIfEnabled(); _triggerSyncIfEnabled();
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Error deleting intake: $e'); printLog('Error deleting intake: $e');
} }
} }
} }
@@ -407,7 +357,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
_triggerSyncIfEnabled(); _triggerSyncIfEnabled();
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Error permanently deleting intake: $e'); printLog('Error permanently deleting intake: $e');
} }
} }
} }
@@ -420,10 +370,26 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
return _todayIntakes.where((intake) => intake['supplement_id'] == supplementId).length; return _todayIntakes.where((intake) => intake['supplement_id'] == supplementId).length;
} }
Map<String, double> get dailyIngredientIntake {
final Map<String, double> ingredientIntake = {};
for (final intake in _todayIntakes) {
final supplement = _supplements.firstWhere((s) => s.id == intake['supplement_id']);
final unitsTaken = intake['unitsTaken'] as double;
for (final ingredient in supplement.ingredients) {
final currentAmount = ingredientIntake[ingredient.name] ?? 0;
ingredientIntake[ingredient.name] = currentAmount + (ingredient.amount * unitsTaken);
}
}
return ingredientIntake;
}
// Method to manually refresh daily status (useful for testing or manual refresh) // Method to manually refresh daily status (useful for testing or manual refresh)
Future<void> refreshDailyStatus() async { Future<void> refreshDailyStatus() async {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Manually refreshing daily status...'); printLog('Manually refreshing daily status...');
} }
_lastDateCheck = DateTime.now(); _lastDateCheck = DateTime.now();
await loadTodayIntakes(); await loadTodayIntakes();
@@ -436,20 +402,20 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
final lastCheckDate = DateTime(_lastDateCheck.year, _lastDateCheck.month, _lastDateCheck.day); final lastCheckDate = DateTime(_lastDateCheck.year, _lastDateCheck.month, _lastDateCheck.day);
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Force checking date change...'); printLog('Force checking date change...');
print('SupplementsLog: Current date: $currentDate'); printLog('Current date: $currentDate');
print('SupplementsLog: Last check date: $lastCheckDate'); printLog('Last check date: $lastCheckDate');
} }
if (currentDate != lastCheckDate) { if (currentDate != lastCheckDate) {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Date change detected, refreshing intakes...'); printLog('Date change detected, refreshing intakes...');
} }
_lastDateCheck = now; _lastDateCheck = now;
await loadTodayIntakes(); await loadTodayIntakes();
} else { } else {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: No date change detected'); printLog('No date change detected');
} }
} }
} }
@@ -464,7 +430,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Error loading archived supplements: $e'); printLog('Error loading archived supplements: $e');
} }
} }
} }
@@ -479,7 +445,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
_triggerSyncIfEnabled(); _triggerSyncIfEnabled();
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Error archiving supplement: $e'); printLog('Error archiving supplement: $e');
} }
} }
} }
@@ -494,7 +460,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
_triggerSyncIfEnabled(); _triggerSyncIfEnabled();
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Error unarchiving supplement: $e'); printLog('Error unarchiving supplement: $e');
} }
} }
} }
@@ -508,32 +474,39 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
_triggerSyncIfEnabled(); _triggerSyncIfEnabled();
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Error permanently deleting archived supplement: $e'); printLog('Error permanently deleting archived supplement: $e');
} }
} }
} }
// Debug methods for notification testing // Debug methods for notification testing
Future<void> testNotifications() async { Future<void> testNotifications() async {
await _notificationService.testNotification(); await _notificationService.showInstant(
title: 'Test Notification',
body: 'This is a test notification to verify the system is working.',
);
} }
Future<void> testScheduledNotification() async { Future<void> testScheduledNotification() async {
await _notificationService.testScheduledNotification(); await _notificationService.showInstant(
title: 'Test Scheduled Notification',
body: 'This is a simple test notification.',
);
} }
Future<void> testNotificationActions() async { Future<void> testNotificationActions() async {
await _notificationService.testNotificationWithActions(); await _notificationService.showInstant(
title: 'Test Action Notification',
body: 'Actions are not available in the simple notification service.',
);
} }
Future<List<PendingNotificationRequest>> getPendingNotifications() async { Future<List<PendingNotificationRequest>> getPendingNotifications() async {
return await _notificationService.getPendingNotifications(); // Not supported in simple service; return empty list for compatibility.
return [];
} }
// Get pending notifications with retry information from database
Future<List<Map<String, dynamic>>> getTrackedNotifications() async {
return await DatabaseHelper.instance.getPendingNotifications();
}
// Debug method to test notification persistence // Debug method to test notification persistence
Future<void> rescheduleAllNotifications() async { Future<void> rescheduleAllNotifications() async {
@@ -542,6 +515,6 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
// Debug method to cancel all notifications // Debug method to cancel all notifications
Future<void> cancelAllNotifications() async { Future<void> cancelAllNotifications() async {
await _notificationService.cancelAllReminders(); await _notificationService.cancelAll();
} }
} }

View File

@@ -52,7 +52,7 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
final _notesController = TextEditingController(); final _notesController = TextEditingController();
// Multi-ingredient support with persistent controllers // Multi-ingredient support with persistent controllers
List<IngredientController> _ingredientControllers = []; final _ingredientControllers = [];
String _selectedUnitType = 'capsules'; String _selectedUnitType = 'capsules';
int _frequencyPerDay = 1; int _frequencyPerDay = 1;
@@ -557,21 +557,24 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
void _saveSupplement() async { void _saveSupplement() async {
if (_formKey.currentState!.validate()) { if (_formKey.currentState!.validate()) {
// Validate that we have at least one ingredient with name and amount // Validate that we have at least one ingredient with name and amount
final validIngredients = _ingredientControllers.where((controller) => final validIngredients = _ingredientControllers
controller.nameController.text.trim().isNotEmpty && .where((controller) =>
(double.tryParse(controller.amountController.text) ?? 0) > 0 controller.nameController.text.trim().isNotEmpty &&
).map((controller) => Ingredient( (double.tryParse(controller.amountController.text) ?? 0) > 0)
name: controller.nameController.text.trim(), .map((controller) => Ingredient(
amount: double.tryParse(controller.amountController.text) ?? 0.0, name: controller.nameController.text.trim(),
unit: controller.selectedUnit, amount: double.tryParse(controller.amountController.text) ?? 0.0,
syncId: const Uuid().v4(), unit: controller.selectedUnit,
lastModified: DateTime.now(), syncId: const Uuid().v4(),
)).toList(); lastModified: DateTime.now(),
))
.toList();
if (validIngredients.isEmpty) { if (validIngredients.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Please add at least one ingredient with name and amount'), content:
Text('Please add at least one ingredient with name and amount'),
), ),
); );
return; return;
@@ -580,14 +583,20 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
final supplement = Supplement( final supplement = Supplement(
id: widget.supplement?.id, id: widget.supplement?.id,
name: _nameController.text.trim(), name: _nameController.text.trim(),
brand: _brandController.text.trim().isNotEmpty ? _brandController.text.trim() : null, brand: _brandController.text.trim().isNotEmpty
? _brandController.text.trim()
: null,
ingredients: validIngredients, ingredients: validIngredients,
numberOfUnits: int.parse(_numberOfUnitsController.text), numberOfUnits: int.parse(_numberOfUnitsController.text),
unitType: _selectedUnitType, unitType: _selectedUnitType,
frequencyPerDay: _frequencyPerDay, frequencyPerDay: _frequencyPerDay,
reminderTimes: _reminderTimes, reminderTimes: _reminderTimes,
notes: _notesController.text.trim().isNotEmpty ? _notesController.text.trim() : null, notes: _notesController.text.trim().isNotEmpty
? _notesController.text.trim()
: null,
createdAt: widget.supplement?.createdAt ?? DateTime.now(), createdAt: widget.supplement?.createdAt ?? DateTime.now(),
syncId: widget.supplement?.syncId, // Preserve syncId on update
lastModified: DateTime.now(), // Always update lastModified on save
); );
final provider = context.read<SupplementProvider>(); final provider = context.read<SupplementProvider>();

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:supplements/widgets/info_chip.dart';
import '../models/supplement.dart'; import '../models/supplement.dart';
import '../providers/supplement_provider.dart'; import '../providers/supplement_provider.dart';
@@ -306,13 +307,13 @@ class _ArchivedSupplementCard extends StatelessWidget {
// Dosage info // Dosage info
Row( Row(
children: [ children: [
_InfoChip( InfoChip(
icon: Icons.schedule, icon: Icons.schedule,
label: '${supplement.frequencyPerDay}x daily', label: '${supplement.frequencyPerDay}x daily',
context: context, context: context,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
_InfoChip( InfoChip(
icon: Icons.medication, icon: Icons.medication,
label: '${supplement.numberOfUnits} ${supplement.unitType}', label: '${supplement.numberOfUnits} ${supplement.unitType}',
context: context, context: context,
@@ -322,7 +323,7 @@ class _ArchivedSupplementCard extends StatelessWidget {
if (supplement.reminderTimes.isNotEmpty) ...[ if (supplement.reminderTimes.isNotEmpty) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
_InfoChip( InfoChip(
icon: Icons.notifications_off, icon: Icons.notifications_off,
label: 'Was: ${supplement.reminderTimes.join(', ')}', label: 'Was: ${supplement.reminderTimes.join(', ')}',
context: context, context: context,
@@ -336,51 +337,3 @@ class _ArchivedSupplementCard extends StatelessWidget {
); );
} }
} }
class _InfoChip extends StatelessWidget {
final IconData icon;
final String label;
final BuildContext context;
final bool fullWidth;
const _InfoChip({
required this.icon,
required this.label,
required this.context,
this.fullWidth = false,
});
@override
Widget build(BuildContext context) {
return Container(
width: fullWidth ? double.infinity : null,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.4),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: fullWidth ? MainAxisSize.max : MainAxisSize.min,
children: [
Icon(
icon,
size: 14,
color: Theme.of(context).colorScheme.outline,
),
const SizedBox(width: 4),
Flexible(
child: Text(
label,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.outline,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}

View File

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

View File

@@ -1,11 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/supplement_provider.dart'; import '../providers/supplement_provider.dart';
import '../providers/settings_provider.dart';
import 'supplements_list_screen.dart';
import 'history_screen.dart';
import 'add_supplement_screen.dart'; import 'add_supplement_screen.dart';
import 'history_screen.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
import 'supplements_list_screen.dart';
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
const HomeScreen({super.key}); const HomeScreen({super.key});
@@ -28,45 +28,10 @@ class _HomeScreenState extends State<HomeScreen> {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<SupplementProvider>().initialize(); context.read<SupplementProvider>().initialize();
_startPersistentReminderCheck();
}); });
} }
void _startPersistentReminderCheck() { // Persistent reminder checks removed
// Check immediately and then every 3 minutes (faster than any retry interval)
_checkPersistentReminders();
// Set up periodic checking every 3 minutes to ensure we catch all retry intervals
Future.doWhile(() async {
await Future.delayed(const Duration(minutes: 3));
if (mounted) {
await _checkPersistentReminders();
return true;
}
return false;
});
}
Future<void> _checkPersistentReminders() async {
if (!mounted) return;
try {
print('SupplementsLog: 📱 === HOME SCREEN: Checking persistent reminders ===');
final supplementProvider = context.read<SupplementProvider>();
final settingsProvider = context.read<SettingsProvider>();
print('SupplementsLog: 📱 Settings: persistent=${settingsProvider.persistentReminders}, interval=${settingsProvider.reminderRetryInterval}, max=${settingsProvider.maxRetryAttempts}');
await supplementProvider.checkPersistentRemindersWithSettings(
persistentReminders: settingsProvider.persistentReminders,
reminderRetryInterval: settingsProvider.reminderRetryInterval,
maxRetryAttempts: settingsProvider.maxRetryAttempts,
);
print('SupplementsLog: 📱 === HOME SCREEN: Persistent reminder check complete ===');
} catch (e) {
print('SupplementsLog: Error checking persistent reminders: $e');
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@@ -1,823 +1,58 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:provider/provider.dart';
import '../services/notification_service.dart';
import '../services/database_helper.dart';
import '../providers/settings_provider.dart';
import '../providers/supplement_provider.dart';
class PendingNotificationsScreen extends StatefulWidget { /// Simple placeholder screen for pending notifications.
/// In the simplified notification setup, we no longer track or retry notifications,
/// so there is no "pending notifications" list to display.
///
/// Keep this screen minimal to avoid heavy logic and dependencies.
class PendingNotificationsScreen extends StatelessWidget {
const PendingNotificationsScreen({super.key}); const PendingNotificationsScreen({super.key});
@override
State<PendingNotificationsScreen> createState() => _PendingNotificationsScreenState();
}
class _PendingNotificationsScreenState extends State<PendingNotificationsScreen> {
List<PendingNotificationRequest> _pendingNotifications = [];
List<Map<String, dynamic>> _trackedNotifications = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadNotifications();
}
Future<void> _loadNotifications() async {
setState(() {
_isLoading = true;
});
try {
// Get settings for retry interval calculation
final settingsProvider = Provider.of<SettingsProvider>(context, listen: false);
final reminderRetryInterval = settingsProvider.reminderRetryInterval;
final maxRetryAttempts = settingsProvider.maxRetryAttempts;
// Get system pending notifications
final notificationService = NotificationService();
final systemPending = await notificationService.getPendingNotifications();
// Get tracked notifications from database (including retries)
final trackedNotifications = await DatabaseHelper.instance.getPendingNotifications();
// Create a more intelligent matching system
final allNotifications = <Map<String, dynamic>>[];
final matchedSystemIds = <int>{};
// First, try to match tracked notifications with system notifications
for (final trackedNotification in trackedNotifications) {
final scheduledTime = DateTime.parse(trackedNotification['scheduledTime']).toLocal();
final lastRetryTime = trackedNotification['lastRetryTime'] != null
? DateTime.parse(trackedNotification['lastRetryTime']).toLocal()
: null;
final retryCount = trackedNotification['retryCount'] ?? 0;
final isRetrying = trackedNotification['status'] == 'retrying';
final notificationId = trackedNotification['notificationId'] as int;
// Try to find matching system notification(s)
final matchingSystemNotifications = systemPending.where((systemNotification) {
return _isMatchingNotification(systemNotification, trackedNotification, retryCount);
}).toList();
// Calculate next retry time if this is a retry notification
DateTime? nextRetryTime;
bool hasReachedMaxRetries = retryCount >= maxRetryAttempts;
if (isRetrying && !hasReachedMaxRetries) {
if (lastRetryTime != null) {
// Next retry is based on last retry time + interval
nextRetryTime = lastRetryTime.add(Duration(minutes: reminderRetryInterval));
} else {
// First retry is based on original scheduled time + interval
nextRetryTime = scheduledTime.add(Duration(minutes: reminderRetryInterval));
}
}
// Create the notification entry
final notificationEntry = {
'id': notificationId,
'title': 'Time for ${trackedNotification['supplementName'] ?? 'Supplement'}',
'body': 'Take your supplement',
'scheduledTime': scheduledTime,
'nextRetryTime': nextRetryTime,
'lastRetryTime': lastRetryTime,
'type': matchingSystemNotifications.isNotEmpty ? 'matched' : 'tracked_only',
'status': trackedNotification['status'],
'isRetry': isRetrying,
'retryCount': retryCount,
'hasReachedMaxRetries': hasReachedMaxRetries,
'maxRetryAttempts': maxRetryAttempts,
'supplementName': trackedNotification['supplementName'],
'supplementId': trackedNotification['supplementId'],
'systemNotificationCount': matchingSystemNotifications.length,
};
allNotifications.add(notificationEntry);
// Mark these system notifications as matched
for (final systemNotification in matchingSystemNotifications) {
matchedSystemIds.add(systemNotification.id);
}
}
// Add unmatched system notifications
for (final systemNotification in systemPending) {
if (!matchedSystemIds.contains(systemNotification.id)) {
allNotifications.add({
'id': systemNotification.id,
'title': systemNotification.title ?? 'System Notification',
'body': systemNotification.body ?? '',
'scheduledTime': DateTime.now(), // PendingNotificationRequest doesn't have scheduledTime
'type': 'system_only',
'isRetry': false,
'retryCount': 0,
'hasReachedMaxRetries': false,
'maxRetryAttempts': maxRetryAttempts,
});
}
}
// Sort by scheduled time (soonest first)
allNotifications.sort((a, b) {
final timeA = a['scheduledTime'] as DateTime;
final timeB = b['scheduledTime'] as DateTime;
return timeA.compareTo(timeB);
});
setState(() {
_pendingNotifications = systemPending;
_trackedNotifications = trackedNotifications;
_isLoading = false;
});
} catch (e) {
print('SupplementsLog: Error loading notifications: $e');
setState(() {
_isLoading = false;
});
}
}
Future<void> _showCleanupDialog() async {
final result = await showDialog<String>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Cleanup Old Notifications'),
content: const Text(
'This will help clean up old or duplicate notifications:\n\n'
'• Clear stale system notifications\n'
'• Remove very old tracked notifications (>24h overdue)\n'
'• Reschedule fresh notifications for active supplements\n\n'
'Choose an option:',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop('cancel'),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop('clear_old'),
child: const Text('Clear Old Only'),
),
TextButton(
onPressed: () => Navigator.of(context).pop('clear_all'),
child: const Text('Clear All & Reschedule'),
),
],
);
},
);
if (result != null && result != 'cancel') {
await _performCleanup(result);
}
}
Future<void> _performCleanup(String action) async {
try {
// Show loading indicator
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Cleaning up notifications...'),
duration: Duration(seconds: 2),
),
);
}
final notificationService = NotificationService();
if (action == 'clear_all') {
// Clear all notifications and reschedule fresh ones
await notificationService.cancelAllReminders();
await DatabaseHelper.instance.cleanupOldNotificationTracking();
// Reschedule notifications for all active supplements
final supplementProvider = Provider.of<SupplementProvider>(context, listen: false);
await supplementProvider.rescheduleAllNotifications();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('All notifications cleared and rescheduled!'),
backgroundColor: Colors.green,
),
);
}
} else if (action == 'clear_old') {
// Clear only very old notifications (>24 hours overdue)
await _clearOldNotifications();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Old notifications cleared!'),
backgroundColor: Colors.orange,
),
);
}
}
// Refresh the list
await _loadNotifications();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error during cleanup: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
Future<void> _clearOldNotifications() async {
final now = DateTime.now();
final cutoff = now.subtract(const Duration(hours: 24));
// Clear very old tracked notifications
final db = await DatabaseHelper.instance.database;
await db.delete(
'notification_tracking',
where: 'scheduledTime < ? AND status IN (?, ?)',
whereArgs: [cutoff.toIso8601String(), 'pending', 'retrying'],
);
// Note: We can't selectively clear system notifications as we don't have their scheduled times
// This is a limitation of the flutter_local_notifications plugin
}
bool _isMatchingNotification(PendingNotificationRequest systemNotification, Map<String, dynamic> trackedNotification, int retryCount) {
final trackedId = trackedNotification['notificationId'] as int;
final supplementId = trackedNotification['supplementId'] as int;
// Check for exact ID match (original notification)
if (systemNotification.id == trackedId) {
return true;
}
// Check for retry notification IDs (200000 + original_id * 10 + retry_attempt)
for (int attempt = 1; attempt <= retryCount; attempt++) {
final retryId = 200000 + (trackedId * 10) + attempt;
if (systemNotification.id == retryId) {
return true;
}
}
// Check for snooze notification IDs (supplementId * 1000 + minutes)
// Common snooze intervals: 5, 10, 15, 30 minutes
final snoozeIds = [5, 10, 15, 30].map((minutes) => supplementId * 1000 + minutes);
if (snoozeIds.contains(systemNotification.id)) {
return true;
}
// Check if it's within the supplement's notification ID range (supplementId * 100 + reminderIndex)
final baseId = supplementId * 100;
if (systemNotification.id >= baseId && systemNotification.id < baseId + 10) {
return true;
}
return false;
}
Color _getTypeColor(String type) {
switch (type) {
case 'matched':
return Colors.green;
case 'tracked_only':
return Colors.orange;
case 'system_only':
return Colors.blue;
default:
return Colors.grey;
}
}
Color _getTypeDarkColor(String type) {
switch (type) {
case 'matched':
return Colors.green.shade700;
case 'tracked_only':
return Colors.orange.shade700;
case 'system_only':
return Colors.blue.shade700;
default:
return Colors.grey.shade700;
}
}
String _getTypeLabel(String type, Map<String, dynamic> notification) {
final systemCount = notification['systemNotificationCount'] as int? ?? 0;
switch (type) {
case 'matched':
return systemCount > 1 ? 'Matched' : 'Synced';
case 'tracked_only':
return 'Tracking Only';
case 'system_only':
return 'System Only';
default:
return 'Unknown';
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Pending Notifications'), title: const Text('Pending Notifications'),
actions: [
IconButton(
icon: const Icon(Icons.cleaning_services),
onPressed: _showCleanupDialog,
tooltip: 'Cleanup old notifications',
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadNotifications,
tooltip: 'Refresh',
),
],
), ),
body: _isLoading body: Center(
? const Center(child: CircularProgressIndicator()) child: Padding(
: RefreshIndicator( padding: const EdgeInsets.all(24),
onRefresh: _loadNotifications, child: Column(
child: _buildNotificationsList(), mainAxisSize: MainAxisSize.min,
), children: [
); const Icon(
} Icons.notifications_none,
size: 72,
Widget _buildNotificationsList() {
if (_pendingNotifications.isEmpty && _trackedNotifications.isEmpty) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.notifications_off,
size: 64,
color: Colors.grey,
),
SizedBox(height: 16),
Text(
'No pending notifications',
style: TextStyle(
fontSize: 18,
color: Colors.grey, color: Colors.grey,
), ),
), const SizedBox(height: 16),
SizedBox(height: 8), Text(
Text( 'No pending notifications UI in simple mode',
'All caught up!', textAlign: TextAlign.center,
style: TextStyle( style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.grey, color: Colors.grey.shade800,
fontWeight: FontWeight.w600,
),
), ),
),
],
),
);
}
// Get settings for retry interval calculation
final settingsProvider = Provider.of<SettingsProvider>(context, listen: false);
final reminderRetryInterval = settingsProvider.reminderRetryInterval;
final maxRetryAttempts = settingsProvider.maxRetryAttempts;
// Create a more intelligent matching system
final allNotifications = <Map<String, dynamic>>[];
final matchedSystemIds = <int>{};
// First, try to match tracked notifications with system notifications
for (final trackedNotification in _trackedNotifications) {
final scheduledTime = DateTime.parse(trackedNotification['scheduledTime']).toLocal();
final lastRetryTime = trackedNotification['lastRetryTime'] != null
? DateTime.parse(trackedNotification['lastRetryTime']).toLocal()
: null;
final retryCount = trackedNotification['retryCount'] ?? 0;
final isRetrying = trackedNotification['status'] == 'retrying';
final notificationId = trackedNotification['notificationId'] as int;
// Try to find matching system notification(s)
final matchingSystemNotifications = _pendingNotifications.where((systemNotification) {
return _isMatchingNotification(systemNotification, trackedNotification, retryCount);
}).toList();
// Calculate next retry time if this is a retry notification
DateTime? nextRetryTime;
bool hasReachedMaxRetries = retryCount >= maxRetryAttempts;
if (isRetrying && !hasReachedMaxRetries) {
if (lastRetryTime != null) {
// Next retry is based on last retry time + interval
nextRetryTime = lastRetryTime.add(Duration(minutes: reminderRetryInterval));
} else {
// First retry is based on original scheduled time + interval
nextRetryTime = scheduledTime.add(Duration(minutes: reminderRetryInterval));
}
}
// Create the notification entry
final notificationEntry = {
'id': notificationId,
'title': 'Time for ${trackedNotification['supplementName'] ?? 'Supplement'}',
'body': 'Take your supplement',
'scheduledTime': scheduledTime,
'nextRetryTime': nextRetryTime,
'lastRetryTime': lastRetryTime,
'type': matchingSystemNotifications.isNotEmpty ? 'matched' : 'tracked_only',
'status': trackedNotification['status'],
'isRetry': isRetrying,
'retryCount': retryCount,
'hasReachedMaxRetries': hasReachedMaxRetries,
'maxRetryAttempts': maxRetryAttempts,
'supplementName': trackedNotification['supplementName'],
'supplementId': trackedNotification['supplementId'],
'systemNotificationCount': matchingSystemNotifications.length,
};
allNotifications.add(notificationEntry);
// Mark these system notifications as matched
for (final systemNotification in matchingSystemNotifications) {
matchedSystemIds.add(systemNotification.id);
}
}
// Add unmatched system notifications
for (final systemNotification in _pendingNotifications) {
if (!matchedSystemIds.contains(systemNotification.id)) {
allNotifications.add({
'id': systemNotification.id,
'title': systemNotification.title ?? 'System Notification',
'body': systemNotification.body ?? '',
'scheduledTime': DateTime.now(), // PendingNotificationRequest doesn't have scheduledTime
'type': 'system_only',
'isRetry': false,
'retryCount': 0,
'hasReachedMaxRetries': false,
'maxRetryAttempts': maxRetryAttempts,
});
}
}
// Sort by scheduled time (soonest first)
allNotifications.sort((a, b) {
final timeA = a['scheduledTime'] as DateTime;
final timeB = b['scheduledTime'] as DateTime;
return timeA.compareTo(timeB);
});
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: allNotifications.length,
itemBuilder: (context, index) {
final notification = allNotifications[index];
return _buildNotificationCard(notification);
},
);
}
Widget _buildNotificationCard(Map<String, dynamic> notification) {
final scheduledTime = notification['scheduledTime'] as DateTime;
final nextRetryTime = notification['nextRetryTime'] as DateTime?;
final lastRetryTime = notification['lastRetryTime'] as DateTime?;
final now = DateTime.now();
final isOverdue = scheduledTime.isBefore(now);
final isRetry = notification['isRetry'] as bool;
final retryCount = notification['retryCount'] as int;
final hasReachedMaxRetries = notification['hasReachedMaxRetries'] as bool? ?? false;
final maxRetryAttempts = notification['maxRetryAttempts'] as int? ?? 3;
final type = notification['type'] as String;
// Determine which time to show and calculate time text
DateTime displayTime = scheduledTime;
String timePrefix = '';
bool showAsNextRetry = false;
String timeText = '';
if (isRetry && nextRetryTime != null && !hasReachedMaxRetries) {
// For retry notifications that haven't reached max retries, show next retry time
final timeDifference = nextRetryTime.difference(now);
if (timeDifference.inSeconds.abs() < 30) {
// If within 30 seconds, show "due now"
displayTime = nextRetryTime;
timePrefix = 'Next retry: ';
showAsNextRetry = true;
timeText = 'due now';
} else if (nextRetryTime.isAfter(now)) {
displayTime = nextRetryTime;
timePrefix = 'Next retry: ';
showAsNextRetry = true;
} else {
// If next retry time has passed, show it's overdue for retry
displayTime = nextRetryTime;
timePrefix = 'Retry overdue: ';
showAsNextRetry = true;
}
} else if (isRetry && hasReachedMaxRetries) {
// For notifications that have reached max retries, don't show next retry time
showAsNextRetry = false;
}
// Calculate time text if not already set
if (timeText.isEmpty) {
final isDisplayTimeOverdue = displayTime.isBefore(now);
final timeUntil = displayTime.difference(now);
if (isDisplayTimeOverdue) {
final overdue = now.difference(displayTime);
if (overdue.inDays > 0) {
timeText = 'Overdue by ${overdue.inDays}d ${overdue.inHours % 24}h';
} else if (overdue.inHours > 0) {
timeText = 'Overdue by ${overdue.inHours}h ${overdue.inMinutes % 60}m';
} else if (overdue.inMinutes > 0) {
timeText = 'Overdue by ${overdue.inMinutes}m';
} else {
timeText = 'Overdue by ${overdue.inSeconds}s';
}
} else {
if (timeUntil.inDays > 0) {
timeText = 'In ${timeUntil.inDays}d ${timeUntil.inHours % 24}h';
} else if (timeUntil.inHours > 0) {
timeText = 'In ${timeUntil.inHours}h ${timeUntil.inMinutes % 60}m';
} else if (timeUntil.inMinutes > 0) {
timeText = 'In ${timeUntil.inMinutes}m';
} else {
timeText = 'In ${timeUntil.inSeconds}s';
}
}
}
// Determine if display time is overdue (needed for icon colors later)
final isDisplayTimeOverdue = displayTime.isBefore(now);
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
notification['title'] ?? 'Notification',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
if (notification['body'] != null) ...[
const SizedBox(height: 4),
Text(
notification['body'],
style: Theme.of(context).textTheme.bodyMedium,
),
],
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (isRetry) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: hasReachedMaxRetries
? Colors.red.withOpacity(0.2)
: Colors.orange.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: hasReachedMaxRetries
? Colors.red.withOpacity(0.5)
: Colors.orange.withOpacity(0.5),
),
),
child: Text(
hasReachedMaxRetries
? 'Max retries ($retryCount/$maxRetryAttempts)'
: 'Retry #$retryCount',
style: TextStyle(
fontSize: 12,
color: hasReachedMaxRetries
? Colors.red.shade700
: Colors.orange.shade700,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 4),
],
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: _getTypeColor(type).withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _getTypeColor(type).withOpacity(0.5),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_getTypeLabel(type, notification),
style: TextStyle(
fontSize: 12,
color: _getTypeDarkColor(type),
fontWeight: FontWeight.bold,
),
),
if (type == 'matched' && (notification['systemNotificationCount'] as int? ?? 0) > 1) ...[
const SizedBox(width: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.8),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${notification['systemNotificationCount']}',
style: TextStyle(
fontSize: 10,
color: _getTypeDarkColor(type),
fontWeight: FontWeight.bold,
),
),
),
],
],
),
),
],
),
],
),
const SizedBox(height: 12),
// Show original scheduled time
Row(
children: [
Icon(
isOverdue ? Icons.schedule_outlined : Icons.access_time,
size: 16,
color: isOverdue ? Colors.red : Colors.grey,
),
const SizedBox(width: 8),
Text(
'Scheduled: ${_formatTime(scheduledTime)}',
style: TextStyle(
color: isOverdue ? Colors.red : Colors.grey.shade700,
fontWeight: isOverdue ? FontWeight.bold : FontWeight.normal,
),
),
if (isOverdue) ...[
const SizedBox(width: 8),
Text(
'(${_formatOverdueTime(scheduledTime)})',
style: TextStyle(
color: Colors.red,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
],
),
// Show next retry time if applicable
if (showAsNextRetry && nextRetryTime != null) ...[
const SizedBox(height: 6),
Row(
children: [
Icon(
isDisplayTimeOverdue ? Icons.warning_outlined : Icons.refresh,
size: 16,
color: isDisplayTimeOverdue ? Colors.red : Colors.orange,
),
const SizedBox(width: 8),
Text(
'${timePrefix}${_formatTime(displayTime)}$timeText',
style: TextStyle(
color: isDisplayTimeOverdue ? Colors.red : Colors.orange.shade700,
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
],
),
],
// Show max retries reached message
if (isRetry && hasReachedMaxRetries) ...[
const SizedBox(height: 6),
Row(
children: [
const Icon(
Icons.block,
size: 16,
color: Colors.red,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Maximum retry attempts reached. No more reminders will be sent.',
style: TextStyle(
color: Colors.red.shade700,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
),
],
// Show last retry time if available
if (isRetry && lastRetryTime != null) ...[
const SizedBox(height: 6),
Row(
children: [
const Icon(
Icons.history,
size: 16,
color: Colors.grey,
),
const SizedBox(width: 8),
Text(
'Last retry: ${_formatTime(lastRetryTime)}',
style: TextStyle(
color: Colors.grey.shade600,
fontSize: 12,
fontStyle: FontStyle.italic,
),
),
],
),
],
if (type == 'tracked' && notification['supplementName'] != null) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Row( Text(
children: [ 'Notifications are now scheduled daily at the selected times '
const Icon( 'without retries or tracking.',
Icons.medication, textAlign: TextAlign.center,
size: 16, style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey, color: Colors.grey.shade600,
),
const SizedBox(width: 8),
Text(
notification['supplementName'],
style: TextStyle(
color: Colors.grey.shade700,
fontStyle: FontStyle.italic,
), ),
), ),
], const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: () => Navigator.of(context).maybePop(),
icon: const Icon(Icons.close),
label: const Text('Close'),
), ),
], ],
], ),
), ),
), ),
); );
} }
String _formatTime(DateTime dateTime) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final tomorrow = today.add(const Duration(days: 1));
final notificationDate = DateTime(dateTime.year, dateTime.month, dateTime.day);
String timeStr = '${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
if (notificationDate == today) {
return 'Today $timeStr';
} else if (notificationDate == tomorrow) {
return 'Tomorrow $timeStr';
} else {
return '${dateTime.day}/${dateTime.month} $timeStr';
}
}
String _formatOverdueTime(DateTime scheduledTime) {
final now = DateTime.now();
final overdue = now.difference(scheduledTime);
if (overdue.inDays > 0) {
return '${overdue.inDays}d ${overdue.inHours % 24}h overdue';
} else if (overdue.inHours > 0) {
return '${overdue.inHours}h ${overdue.inMinutes % 60}m overdue';
} else {
return '${overdue.inMinutes}m overdue';
}
}
} }

View File

@@ -1,10 +1,9 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/settings_provider.dart'; import '../providers/settings_provider.dart';
import '../providers/supplement_provider.dart'; import 'debug_notifications_screen.dart';
import '../services/notification_service.dart';
import 'pending_notifications_screen.dart';
import 'simple_sync_settings_screen.dart'; import 'simple_sync_settings_screen.dart';
class SettingsScreen extends StatelessWidget { class SettingsScreen extends StatelessWidget {
@@ -21,6 +20,25 @@ class SettingsScreen extends StatelessWidget {
return ListView( return ListView(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
children: [ children: [
// Debug section (only in debug builds)
if (kDebugMode) ...[
Card(
child: ListTile(
leading: const Icon(Icons.bug_report),
title: const Text('Debug Notifications'),
subtitle: const Text('View scheduled notifications and debug log'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const DebugNotificationsScreen(),
),
);
},
),
),
const SizedBox(height: 16),
],
Card( Card(
child: ListTile( child: ListTile(
leading: const Icon(Icons.cloud_sync), leading: const Icon(Icons.cloud_sync),
@@ -86,98 +104,25 @@ class SettingsScreen extends StatelessWidget {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Notifications
Card( Card(
child: Padding( child: ListTile(
padding: const EdgeInsets.all(16.0), leading: const Icon(Icons.snooze),
child: Column( title: const Text('Snooze duration'),
crossAxisAlignment: CrossAxisAlignment.start, subtitle: const Text('Delay for Snooze action'),
children: [ trailing: DropdownButton<int>(
Row( value: settingsProvider.snoozeMinutes,
children: [ items: const [
Icon(Icons.notifications_active, color: Colors.blue), DropdownMenuItem(value: 5, child: Text('5 min')),
const SizedBox(width: 8), DropdownMenuItem(value: 10, child: Text('10 min')),
Text( DropdownMenuItem(value: 15, child: Text('15 min')),
'Reminders', DropdownMenuItem(value: 20, child: Text('20 min')),
style: Theme.of(context).textTheme.titleMedium,
),
],
),
const SizedBox(height: 8),
Text(
'Configure reminders and how often they are retried when ignored',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('Enable Persistent Reminders'),
subtitle: const Text('Resend notifications if ignored after a specific time'),
value: settingsProvider.persistentReminders,
onChanged: (value) {
settingsProvider.setPersistentReminders(value);
},
),
if (settingsProvider.persistentReminders) ...[
const SizedBox(height: 16),
Text(
'Retry Interval',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
SegmentedButton<int>(
segments: const [
ButtonSegment(value: 5, label: Text('5 min')),
ButtonSegment(value: 10, label: Text('10 min')),
ButtonSegment(value: 15, label: Text('15 min')),
ButtonSegment(value: 30, label: Text('30 min')),
],
selected: {settingsProvider.reminderRetryInterval},
onSelectionChanged: (values) {
settingsProvider.setReminderRetryInterval(values.first);
},
),
const SizedBox(height: 16),
Text(
'Maximum Retry Attempts',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
SegmentedButton<int>(
segments: const [
ButtonSegment(value: 1, label: Text('1')),
ButtonSegment(value: 2, label: Text('2')),
ButtonSegment(value: 3, label: Text('3')),
ButtonSegment(value: 4, label: Text('4')),
ButtonSegment(value: 5, label: Text('5')),
],
selected: {settingsProvider.maxRetryAttempts},
onSelectionChanged: (values) {
settingsProvider.setMaxRetryAttempts(values.first);
},
),
const SizedBox(height: 16),
Text(
'Notification Actions',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
SizedBox(
width: 320,
child: ElevatedButton.icon(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const PendingNotificationsScreen(),
),
);
},
icon: const Icon(Icons.list),
label: const Text('View Pending Notifications'),
),
),
],
], ],
onChanged: (value) {
if (value != null) {
settingsProvider.setSnoozeMinutes(value);
}
},
), ),
), ),
), ),

View File

@@ -272,125 +272,140 @@ class _SimpleSyncSettingsScreenState extends State<SimpleSyncSettingsScreen> {
} }
Widget _buildConfigurationSection(SimpleSyncProvider syncProvider) { Widget _buildConfigurationSection(SimpleSyncProvider syncProvider) {
return Card( return Column(
child: Padding( crossAxisAlignment: CrossAxisAlignment.stretch,
padding: const EdgeInsets.all(16.0), children: [
child: Column( Card(
crossAxisAlignment: CrossAxisAlignment.start, child: Padding(
children: [ padding: const EdgeInsets.all(12.0),
Text( child: Column(
'Sync Configuration', crossAxisAlignment: CrossAxisAlignment.start,
style: Theme.of(context).textTheme.titleLarge, children: [
), Text(
const SizedBox(height: 16), 'Sync Configuration',
_buildAutoSyncSection(), style: Theme.of(context).textTheme.titleLarge,
const SizedBox(height: 24),
Text(
'WebDAV Settings',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
TextFormField(
controller: _serverUrlController,
decoration: const InputDecoration(
labelText: 'Server URL',
hintText: 'your-nextcloud.com',
helperText: 'Enter just the hostname. We\'ll auto-detect the full WebDAV path.',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a server URL';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: 'Username',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a username';
}
return null;
},
),
if (_previewUrl.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5),
),
), ),
child: Column( const SizedBox(height: 8),
crossAxisAlignment: CrossAxisAlignment.start, _buildAutoSyncSection(),
children: [ ],
Text( ),
'WebDAV URL Preview:', ),
style: Theme.of(context).textTheme.labelMedium, ),
), const SizedBox(height: 12),
const SizedBox(height: 4), Card(
SelectableText( child: Padding(
_previewUrl, padding: const EdgeInsets.all(12.0),
style: Theme.of(context).textTheme.bodySmall?.copyWith( child: Column(
fontFamily: 'monospace', crossAxisAlignment: CrossAxisAlignment.start,
color: Theme.of(context).colorScheme.primary, children: [
Text(
'WebDAV Settings',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
TextFormField(
controller: _serverUrlController,
decoration: const InputDecoration(
labelText: 'Server URL',
hintText: 'your-nextcloud.com',
helperText: 'Enter just the hostname. We\'ll auto-detect the full WebDAV path.',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a server URL';
}
return null;
},
),
const SizedBox(height: 8),
TextFormField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: 'Username',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a username';
}
return null;
},
),
if (_previewUrl.isNotEmpty) ...[
const SizedBox(height: 6),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5),
), ),
), ),
const SizedBox(height: 8), child: Column(
Row( crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
ElevatedButton.icon( Text(
onPressed: syncProvider.isSyncing ? null : _testConnection, 'WebDAV URL Preview:',
icon: const Icon(Icons.link), style: Theme.of(context).textTheme.labelMedium,
label: const Text('Test'), ),
style: ElevatedButton.styleFrom( const SizedBox(height: 4),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), SelectableText(
elevation: 0, _previewUrl,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: Theme.of(context).colorScheme.primary,
), ),
), ),
const SizedBox(height: 6),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ElevatedButton.icon(
onPressed: syncProvider.isSyncing ? null : _testConnection,
icon: const Icon(Icons.link),
label: const Text('Test'),
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
elevation: 0,
),
),
],
),
], ],
), ),
], ),
],
const SizedBox(height: 8),
TextFormField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: 'Password',
border: OutlineInputBorder(),
),
obscureText: true,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a password';
}
return null;
},
), ),
), const SizedBox(height: 8),
], TextFormField(
const SizedBox(height: 16), controller: _remotePathController,
TextFormField( decoration: const InputDecoration(
controller: _passwordController, labelText: 'Remote Path (optional)',
decoration: const InputDecoration( hintText: 'Supplements/',
labelText: 'Password', border: OutlineInputBorder(),
border: OutlineInputBorder(), ),
), ),
obscureText: true, ],
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a password';
}
return null;
},
), ),
const SizedBox(height: 16), ),
TextFormField(
controller: _remotePathController,
decoration: const InputDecoration(
labelText: 'Remote Path (optional)',
hintText: 'Supplements/',
border: OutlineInputBorder(),
),
),
],
), ),
), ],
); );
} }

View File

@@ -3,10 +3,11 @@ import 'package:provider/provider.dart';
import '../models/supplement.dart'; import '../models/supplement.dart';
import '../providers/settings_provider.dart'; import '../providers/settings_provider.dart';
import '../providers/supplement_provider.dart';
import '../providers/simple_sync_provider.dart'; import '../providers/simple_sync_provider.dart';
import '../providers/supplement_provider.dart';
import '../services/database_sync_service.dart'; import '../services/database_sync_service.dart';
import '../widgets/supplement_card.dart'; import '../widgets/supplement_card.dart';
import '../widgets/dialogs/take_supplement_dialog.dart';
import 'add_supplement_screen.dart'; import 'add_supplement_screen.dart';
import 'archived_supplements_screen.dart'; import 'archived_supplements_screen.dart';
@@ -107,6 +108,7 @@ class SupplementsListScreen extends StatelessWidget {
} }
Widget _buildGroupedSupplementsList(BuildContext context, List<Supplement> supplements, SettingsProvider settingsProvider) { Widget _buildGroupedSupplementsList(BuildContext context, List<Supplement> supplements, SettingsProvider settingsProvider) {
final provider = Provider.of<SupplementProvider>(context, listen: false);
final groupedSupplements = _groupSupplementsByTimeOfDay(supplements, settingsProvider); final groupedSupplements = _groupSupplementsByTimeOfDay(supplements, settingsProvider);
return ListView( return ListView(
@@ -117,10 +119,11 @@ class SupplementsListScreen extends StatelessWidget {
...groupedSupplements['morning']!.map((supplement) => ...groupedSupplements['morning']!.map((supplement) =>
SupplementCard( SupplementCard(
supplement: supplement, supplement: supplement,
onTake: () => _showTakeDialog(context, supplement), onTake: () => showTakeSupplementDialog(context, supplement),
onEdit: () => _editSupplement(context, supplement), onEdit: () => _editSupplement(context, supplement),
onDelete: () => _deleteSupplement(context, supplement), onDelete: () => _deleteSupplement(context, supplement),
onArchive: () => _archiveSupplement(context, supplement), onArchive: () => _archiveSupplement(context, supplement),
onDuplicate: () => context.read<SupplementProvider>().duplicateSupplement(supplement.id!),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -131,10 +134,11 @@ class SupplementsListScreen extends StatelessWidget {
...groupedSupplements['afternoon']!.map((supplement) => ...groupedSupplements['afternoon']!.map((supplement) =>
SupplementCard( SupplementCard(
supplement: supplement, supplement: supplement,
onTake: () => _showTakeDialog(context, supplement), onTake: () => showTakeSupplementDialog(context, supplement),
onEdit: () => _editSupplement(context, supplement), onEdit: () => _editSupplement(context, supplement),
onDelete: () => _deleteSupplement(context, supplement), onDelete: () => _deleteSupplement(context, supplement),
onArchive: () => _archiveSupplement(context, supplement), onArchive: () => _archiveSupplement(context, supplement),
onDuplicate: () => context.read<SupplementProvider>().duplicateSupplement(supplement.id!),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -145,10 +149,11 @@ class SupplementsListScreen extends StatelessWidget {
...groupedSupplements['evening']!.map((supplement) => ...groupedSupplements['evening']!.map((supplement) =>
SupplementCard( SupplementCard(
supplement: supplement, supplement: supplement,
onTake: () => _showTakeDialog(context, supplement), onTake: () => showTakeSupplementDialog(context, supplement),
onEdit: () => _editSupplement(context, supplement), onEdit: () => _editSupplement(context, supplement),
onDelete: () => _deleteSupplement(context, supplement), onDelete: () => _deleteSupplement(context, supplement),
onArchive: () => _archiveSupplement(context, supplement), onArchive: () => _archiveSupplement(context, supplement),
onDuplicate: () => context.read<SupplementProvider>().duplicateSupplement(supplement.id!),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -159,10 +164,11 @@ class SupplementsListScreen extends StatelessWidget {
...groupedSupplements['night']!.map((supplement) => ...groupedSupplements['night']!.map((supplement) =>
SupplementCard( SupplementCard(
supplement: supplement, supplement: supplement,
onTake: () => _showTakeDialog(context, supplement), onTake: () => showTakeSupplementDialog(context, supplement),
onEdit: () => _editSupplement(context, supplement), onEdit: () => _editSupplement(context, supplement),
onDelete: () => _deleteSupplement(context, supplement), onDelete: () => _deleteSupplement(context, supplement),
onArchive: () => _archiveSupplement(context, supplement), onArchive: () => _archiveSupplement(context, supplement),
onDuplicate: () => context.read<SupplementProvider>().duplicateSupplement(supplement.id!),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -173,10 +179,11 @@ class SupplementsListScreen extends StatelessWidget {
...groupedSupplements['anytime']!.map((supplement) => ...groupedSupplements['anytime']!.map((supplement) =>
SupplementCard( SupplementCard(
supplement: supplement, supplement: supplement,
onTake: () => _showTakeDialog(context, supplement), onTake: () => showTakeSupplementDialog(context, supplement),
onEdit: () => _editSupplement(context, supplement), onEdit: () => _editSupplement(context, supplement),
onDelete: () => _deleteSupplement(context, supplement), onDelete: () => _deleteSupplement(context, supplement),
onArchive: () => _archiveSupplement(context, supplement), onArchive: () => _archiveSupplement(context, supplement),
onDuplicate: () => context.read<SupplementProvider>().duplicateSupplement(supplement.id!),
), ),
), ),
], ],

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:math'; import 'dart:math';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:supplements/logging.dart';
import '../providers/settings_provider.dart'; import '../providers/settings_provider.dart';
import '../providers/simple_sync_provider.dart'; import '../providers/simple_sync_provider.dart';
@@ -69,7 +70,7 @@ class AutoSyncService {
// Check if auto-sync is enabled // Check if auto-sync is enabled
if (!_settingsProvider.autoSyncEnabled) { if (!_settingsProvider.autoSyncEnabled) {
if (kDebugMode) { if (kDebugMode) {
print('AutoSyncService: Auto-sync is disabled, skipping trigger'); printLog('AutoSyncService: Auto-sync is disabled, skipping trigger');
} }
return; return;
} }
@@ -77,7 +78,7 @@ class AutoSyncService {
// Check if auto-sync was disabled due to persistent errors // Check if auto-sync was disabled due to persistent errors
if (_autoDisabledDueToErrors) { if (_autoDisabledDueToErrors) {
if (kDebugMode) { 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; return;
} }
@@ -90,7 +91,7 @@ class AutoSyncService {
timestamp: DateTime.now(), timestamp: DateTime.now(),
)); ));
if (kDebugMode) { if (kDebugMode) {
print('AutoSyncService: Sync not configured, skipping auto-sync'); printLog('AutoSyncService: Sync not configured, skipping auto-sync');
} }
return; return;
} }
@@ -99,7 +100,7 @@ class AutoSyncService {
if (_syncInProgress || _syncProvider.isSyncing) { if (_syncInProgress || _syncProvider.isSyncing) {
_hasPendingSync = true; _hasPendingSync = true;
if (kDebugMode) { if (kDebugMode) {
print('AutoSyncService: Sync in progress, marking pending sync'); printLog('AutoSyncService: Sync in progress, marking pending sync');
} }
return; return;
} }
@@ -111,7 +112,7 @@ class AutoSyncService {
final backoffDelay = _calculateBackoffDelay(); final backoffDelay = _calculateBackoffDelay();
if (backoffDelay > 0) { if (backoffDelay > 0) {
if (kDebugMode) { 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), () { _debounceTimer = Timer(Duration(seconds: backoffDelay), () {
_executePendingSync(); _executePendingSync();
@@ -126,7 +127,7 @@ class AutoSyncService {
}); });
if (kDebugMode) { 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 // Double-check conditions before executing
if (!_settingsProvider.autoSyncEnabled) { if (!_settingsProvider.autoSyncEnabled) {
if (kDebugMode) { if (kDebugMode) {
print('AutoSyncService: Auto-sync disabled during execution, aborting'); printLog('AutoSyncService: Auto-sync disabled during execution, aborting');
} }
return; return;
} }
if (_autoDisabledDueToErrors) { if (_autoDisabledDueToErrors) {
if (kDebugMode) { 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; return;
} }
@@ -154,14 +155,14 @@ class AutoSyncService {
timestamp: DateTime.now(), timestamp: DateTime.now(),
)); ));
if (kDebugMode) { if (kDebugMode) {
print('AutoSyncService: Sync not configured during execution, aborting'); printLog('AutoSyncService: Sync not configured during execution, aborting');
} }
return; return;
} }
if (_syncInProgress || _syncProvider.isSyncing) { if (_syncInProgress || _syncProvider.isSyncing) {
if (kDebugMode) { if (kDebugMode) {
print('AutoSyncService: Sync already in progress during execution, aborting'); printLog('AutoSyncService: Sync already in progress during execution, aborting');
} }
return; return;
} }
@@ -171,7 +172,7 @@ class AutoSyncService {
try { try {
if (kDebugMode) { if (kDebugMode) {
print('AutoSyncService: Executing auto-sync (attempt ${_consecutiveFailures + 1})'); printLog('AutoSyncService: Executing auto-sync (attempt ${_consecutiveFailures + 1})');
} }
// Check network connectivity before attempting sync // Check network connectivity before attempting sync
@@ -191,12 +192,12 @@ class AutoSyncService {
_autoDisabledDueToErrors = false; _autoDisabledDueToErrors = false;
if (kDebugMode) { if (kDebugMode) {
print('AutoSyncService: Auto-sync completed successfully'); printLog('AutoSyncService: Auto-sync completed successfully');
} }
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('AutoSyncService: Auto-sync failed: $e'); printLog('AutoSyncService: Auto-sync failed: $e');
} }
// Handle specific error types // Handle specific error types
@@ -208,7 +209,7 @@ class AutoSyncService {
// If there was a pending sync request while we were syncing, trigger it // If there was a pending sync request while we were syncing, trigger it
if (_hasPendingSync && !_autoDisabledDueToErrors) { if (_hasPendingSync && !_autoDisabledDueToErrors) {
if (kDebugMode) { if (kDebugMode) {
print('AutoSyncService: Processing queued sync request'); printLog('AutoSyncService: Processing queued sync request');
} }
_hasPendingSync = false; _hasPendingSync = false;
// Use a small delay to avoid immediate re-triggering // Use a small delay to avoid immediate re-triggering
@@ -231,14 +232,14 @@ class AutoSyncService {
if (_consecutiveFailures >= _autoDisableThreshold) { if (_consecutiveFailures >= _autoDisableThreshold) {
_autoDisabledDueToErrors = true; _autoDisabledDueToErrors = true;
if (kDebugMode) { 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 // For configuration errors, disable immediately
if (autoSyncError.type == AutoSyncErrorType.configuration || if (autoSyncError.type == AutoSyncErrorType.configuration ||
autoSyncError.type == AutoSyncErrorType.authentication) { autoSyncError.type == AutoSyncErrorType.authentication) {
if (kDebugMode) { 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) { if (kDebugMode) {
print('AutoSyncService: Recorded error: $error'); printLog('AutoSyncService: Recorded error: $error');
} }
} }
@@ -375,13 +376,13 @@ class AutoSyncService {
_retryTimer?.cancel(); _retryTimer?.cancel();
_retryTimer = Timer(Duration(seconds: retryDelay), () { _retryTimer = Timer(Duration(seconds: retryDelay), () {
if (kDebugMode) { if (kDebugMode) {
print('AutoSyncService: Retrying auto-sync after backoff delay'); printLog('AutoSyncService: Retrying auto-sync after backoff delay');
} }
triggerAutoSync(); triggerAutoSync();
}); });
if (kDebugMode) { 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; return result.isNotEmpty && result[0].rawAddress.isNotEmpty;
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('AutoSyncService: Network check failed: $e'); printLog('AutoSyncService: Network check failed: $e');
} }
return false; return false;
} }
@@ -406,7 +407,7 @@ class AutoSyncService {
_hasPendingSync = false; _hasPendingSync = false;
if (kDebugMode) { 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; _retryTimer = null;
if (kDebugMode) { 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(); _recentErrors.clear();
if (kDebugMode) { if (kDebugMode) {
print('AutoSyncService: Disposed'); printLog('AutoSyncService: Disposed');
} }
} }

View File

@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
import 'package:supplements/logging.dart';
import 'package:webdav_client/webdav_client.dart'; import 'package:webdav_client/webdav_client.dart';
import '../models/supplement.dart'; import '../models/supplement.dart';
@@ -92,7 +93,7 @@ class DatabaseSyncService {
} }
} catch (e) { } catch (e) {
if (kDebugMode) { 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!); if (_configuredRemotePath != null) await prefs.setString(_keyRemotePath, _configuredRemotePath!);
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Error saving sync configuration: $e'); printLog('Error saving sync configuration: $e');
} }
} }
} }
@@ -145,7 +146,7 @@ class DatabaseSyncService {
return true; return true;
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Connection test failed: $e'); printLog('Connection test failed: $e');
} }
return false; return false;
} }
@@ -182,7 +183,7 @@ class DatabaseSyncService {
_setStatus(SyncStatus.error); _setStatus(SyncStatus.error);
onError?.call(_lastError!); onError?.call(_lastError!);
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Sync failed: $e'); printLog('Sync failed: $e');
} }
rethrow; rethrow;
} }
@@ -196,13 +197,13 @@ class DatabaseSyncService {
if (!remoteDbExists) { if (!remoteDbExists) {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: No remote database found, will upload local database'); printLog('No remote database found, will upload local database');
} }
return null; return null;
} }
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Remote database found, downloading...'); printLog('Remote database found, downloading...');
} }
// Download the remote database // Download the remote database
@@ -217,14 +218,14 @@ class DatabaseSyncService {
await tempFile.writeAsBytes(remoteDbBytes); await tempFile.writeAsBytes(remoteDbBytes);
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Downloaded remote database (${remoteDbBytes.length} bytes) to: $tempDbPath'); printLog('Downloaded remote database (${remoteDbBytes.length} bytes) to: $tempDbPath');
} }
return tempDbPath; return tempDbPath;
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Failed to download remote database: $e'); printLog('Failed to download remote database: $e');
} }
return null; return null;
} }
@@ -233,13 +234,13 @@ class DatabaseSyncService {
Future<void> _mergeDatabases(String? remoteDbPath) async { Future<void> _mergeDatabases(String? remoteDbPath) async {
if (remoteDbPath == null) { if (remoteDbPath == null) {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: No remote database to merge'); printLog('No remote database to merge');
} }
return; return;
} }
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Starting database merge from: $remoteDbPath'); printLog('Starting database merge from: $remoteDbPath');
} }
final localDb = await _databaseHelper.database; final localDb = await _databaseHelper.database;
@@ -249,21 +250,21 @@ class DatabaseSyncService {
// Check what tables exist in remote database // Check what tables exist in remote database
if (kDebugMode) { if (kDebugMode) {
final tables = await remoteDb.rawQuery("SELECT name FROM sqlite_master WHERE type='table'"); 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 // Count records in each table
try { try {
final supplementCount = await remoteDb.rawQuery('SELECT COUNT(*) as count FROM supplements'); 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) { } catch (e) {
print('SupplementsLog: Error counting supplements: $e'); printLog('Error counting supplements: $e');
} }
try { try {
final intakeCount = await remoteDb.rawQuery('SELECT COUNT(*) as count FROM supplement_intakes'); 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) { } catch (e) {
print('SupplementsLog: Error counting intakes: $e'); printLog('Error counting intakes: $e');
} }
} }
@@ -274,7 +275,7 @@ class DatabaseSyncService {
await _mergeIntakes(localDb, remoteDb); await _mergeIntakes(localDb, remoteDb);
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Database merge completed successfully'); printLog('Database merge completed successfully');
} }
} finally { } finally {
@@ -284,24 +285,28 @@ class DatabaseSyncService {
Future<void> _mergeSupplements(Database localDb, Database remoteDb) async { Future<void> _mergeSupplements(Database localDb, Database remoteDb) async {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Starting supplement merge...'); printLog('Starting supplement merge...');
} }
// Get all supplements from remote database // Get all supplements from remote database
final remoteMaps = await remoteDb.query('supplements'); final remoteMaps = await remoteDb.query('supplements');
final remoteSupplements = remoteMaps.map((map) => Supplement.fromMap(map)).toList(); final remoteSupplements =
remoteMaps.map((map) => Supplement.fromMap(map)).toList();
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Found ${remoteSupplements.length} supplements in remote database'); printLog(
'Found ${remoteSupplements.length} supplements in remote database');
for (final supplement in remoteSupplements) { 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) { for (final remoteSupplement in remoteSupplements) {
if (remoteSupplement.syncId.isEmpty) { if (remoteSupplement.syncId.isEmpty) {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Skipping supplement ${remoteSupplement.name} - no syncId'); printLog(
'Skipping supplement ${remoteSupplement.name} - no syncId');
} }
continue; continue;
} }
@@ -316,22 +321,28 @@ class DatabaseSyncService {
if (existingMaps.isEmpty) { if (existingMaps.isEmpty) {
// New supplement from remote - insert it // New supplement from remote - insert it
if (!remoteSupplement.isDeleted) { if (!remoteSupplement.isDeleted) {
final supplementToInsert = remoteSupplement.copyWith(id: null); // Manually create a new map without the id to ensure it's null
await localDb.insert('supplements', supplementToInsert.toMap()); final mapToInsert = remoteSupplement.toMap();
mapToInsert.remove('id');
await localDb.insert('supplements', mapToInsert);
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: ✓ Inserted new supplement: ${remoteSupplement.name}'); printLog(
'✓ Inserted new supplement: ${remoteSupplement.name}');
} }
} else { } else {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Skipping deleted supplement: ${remoteSupplement.name}'); printLog(
'Skipping deleted supplement: ${remoteSupplement.name}');
} }
} }
} else { } else {
// Existing supplement - update if remote is newer // Existing supplement - update if remote is newer
final existingSupplement = Supplement.fromMap(existingMaps.first); final existingSupplement = Supplement.fromMap(existingMaps.first);
if (remoteSupplement.lastModified.isAfter(existingSupplement.lastModified)) { if (remoteSupplement.lastModified
final supplementToUpdate = remoteSupplement.copyWith(id: existingSupplement.id); .isAfter(existingSupplement.lastModified)) {
final supplementToUpdate =
remoteSupplement.copyWith(id: existingSupplement.id);
await localDb.update( await localDb.update(
'supplements', 'supplements',
supplementToUpdate.toMap(), supplementToUpdate.toMap(),
@@ -339,24 +350,26 @@ class DatabaseSyncService {
whereArgs: [existingSupplement.id], whereArgs: [existingSupplement.id],
); );
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: ✓ Updated supplement: ${remoteSupplement.name}'); printLog(
'✓ Updated supplement: ${remoteSupplement.name}');
} }
} else { } else {
if (kDebugMode) { 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) { if (kDebugMode) {
print('SupplementsLog: Supplement merge completed'); printLog('Supplement merge completed');
} }
} }
Future<void> _mergeIntakes(Database localDb, Database remoteDb) async { Future<void> _mergeIntakes(Database localDb, Database remoteDb) async {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Starting intake merge...'); printLog('Starting intake merge...');
} }
// Get all intakes from remote database // Get all intakes from remote database
@@ -364,13 +377,13 @@ class DatabaseSyncService {
final remoteIntakes = remoteMaps.map((map) => SupplementIntake.fromMap(map)).toList(); final remoteIntakes = remoteMaps.map((map) => SupplementIntake.fromMap(map)).toList();
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Found ${remoteIntakes.length} intakes in remote database'); printLog('Found ${remoteIntakes.length} intakes in remote database');
} }
for (final remoteIntake in remoteIntakes) { for (final remoteIntake in remoteIntakes) {
if (remoteIntake.syncId.isEmpty) { if (remoteIntake.syncId.isEmpty) {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Skipping intake - no syncId'); printLog('Skipping intake - no syncId');
} }
continue; continue;
} }
@@ -393,16 +406,16 @@ class DatabaseSyncService {
); );
await localDb.insert('supplement_intakes', intakeToInsert.toMap()); await localDb.insert('supplement_intakes', intakeToInsert.toMap());
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: ✓ Inserted new intake: ${remoteIntake.syncId}'); printLog('✓ Inserted new intake: ${remoteIntake.syncId}');
} }
} else { } else {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Could not find local supplement for intake ${remoteIntake.syncId}'); printLog('Could not find local supplement for intake ${remoteIntake.syncId}');
} }
} }
} else { } else {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Skipping deleted intake: ${remoteIntake.syncId}'); printLog('Skipping deleted intake: ${remoteIntake.syncId}');
} }
} }
} else { } else {
@@ -418,18 +431,18 @@ class DatabaseSyncService {
whereArgs: [existingIntake.id], whereArgs: [existingIntake.id],
); );
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: ✓ Updated intake: ${remoteIntake.syncId}'); printLog('✓ Updated intake: ${remoteIntake.syncId}');
} }
} else { } else {
if (kDebugMode) { 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) { if (kDebugMode) {
print('SupplementsLog: Intake merge completed'); printLog('Intake merge completed');
} }
} }
@@ -464,7 +477,7 @@ class DatabaseSyncService {
final dbPath = localDb.path; final dbPath = localDb.path;
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Reading database from: $dbPath'); printLog('Reading database from: $dbPath');
} }
// Read the database file // Read the database file
@@ -476,7 +489,7 @@ class DatabaseSyncService {
final dbBytes = await dbFile.readAsBytes(); final dbBytes = await dbFile.readAsBytes();
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Database file size: ${dbBytes.length} bytes'); printLog('Database file size: ${dbBytes.length} bytes');
} }
if (dbBytes.isEmpty) { if (dbBytes.isEmpty) {
@@ -488,7 +501,7 @@ class DatabaseSyncService {
await _client!.readDir(_remotePath!); await _client!.readDir(_remotePath!);
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Creating remote directory: $_remotePath'); printLog('Creating remote directory: $_remotePath');
} }
await _client!.mkdir(_remotePath!); await _client!.mkdir(_remotePath!);
} }
@@ -498,12 +511,12 @@ class DatabaseSyncService {
await _client!.write(remoteUrl, dbBytes); await _client!.write(remoteUrl, dbBytes);
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Successfully uploaded database (${dbBytes.length} bytes) to: $remoteUrl'); printLog('Successfully uploaded database (${dbBytes.length} bytes) to: $remoteUrl');
} }
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
print('SupplementsLog: Failed to upload database: $e'); printLog('Failed to upload database: $e');
} }
rethrow; rethrow;
} }

View File

@@ -0,0 +1,92 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
class NotificationLogEntry {
final int id;
final String kind; // 'daily' | 'snooze'
final String type; // 'single' | 'group'
final int whenEpochMs; // exact scheduled time (epoch ms)
final int createdAtEpochMs; // when we created the schedule (epoch ms)
final String title;
final String payload;
final int? singleId; // supplement id for single
final String? timeKey; // HH:mm for group
const NotificationLogEntry({
required this.id,
required this.kind,
required this.type,
required this.whenEpochMs,
required this.createdAtEpochMs,
required this.title,
required this.payload,
this.singleId,
this.timeKey,
});
Map<String, dynamic> toJson() => {
'id': id,
'kind': kind,
'type': type,
'when': whenEpochMs,
'createdAt': createdAtEpochMs,
'title': title,
'payload': payload,
'singleId': singleId,
'timeKey': timeKey,
};
static NotificationLogEntry fromJson(Map<String, dynamic> map) {
return NotificationLogEntry(
id: map['id'] is int ? map['id'] as int : int.tryParse('${map['id']}') ?? 0,
kind: map['kind'] ?? 'unknown',
type: map['type'] ?? 'unknown',
whenEpochMs: map['when'] is int ? map['when'] as int : int.tryParse('${map['when']}') ?? 0,
createdAtEpochMs: map['createdAt'] is int ? map['createdAt'] as int : int.tryParse('${map['createdAt']}') ?? 0,
title: map['title'] ?? '',
payload: map['payload'] ?? '',
singleId: map['singleId'],
timeKey: map['timeKey'],
);
}
}
class NotificationDebugStore {
NotificationDebugStore._internal();
static final NotificationDebugStore instance = NotificationDebugStore._internal();
static const String _prefsKey = 'notification_log';
static const int _maxEntries = 200;
Future<List<NotificationLogEntry>> getAll() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_prefsKey);
if (raw == null || raw.isEmpty) return [];
try {
final list = jsonDecode(raw) as List;
return list
.map((e) => NotificationLogEntry.fromJson(e as Map<String, dynamic>))
.toList();
} catch (_) {
return [];
}
}
Future<void> add(NotificationLogEntry entry) async {
final prefs = await SharedPreferences.getInstance();
final current = await getAll();
current.add(entry);
// Cap size
final trimmed = current.length > _maxEntries
? current.sublist(current.length - _maxEntries)
: current;
final serialized = jsonEncode(trimmed.map((e) => e.toJson()).toList());
await prefs.setString(_prefsKey, serialized);
}
Future<void> clear() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_prefsKey);
}
}

View File

@@ -0,0 +1,290 @@
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';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:supplements/services/simple_notification_service.dart';
/// Centralizes routing from notification actions/taps to in-app UI.
/// Handles both foreground/background taps and terminated-launch scenarios.
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');
printLog('🔔 handleNotificationResponse: Received actionId: $actionId');
printLog('🔔 handleNotificationResponse: Decoded payloadMap: $payloadMap');
// Handle Snooze actions without surfacing UI
if (actionId == 'snooze_single' || actionId == 'snooze_group') {
try {
final prefs = await SharedPreferences.getInstance();
final minutes = prefs.getInt('snooze_minutes') ?? 10;
await _scheduleSnoozeFromPayload(payloadMap, Duration(minutes: minutes));
} catch (e) {
printLog('⚠️ Failed to handle snooze action: $e');
}
return;
}
// Default: route to in-app UI for Take actions and normal taps
await _routeFromPayload(payloadMap);
}
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<void> _scheduleSnoozeFromPayload(Map<String, dynamic>? payload, Duration delay) async {
if (payload == null) {
printLog('⚠️ Snooze requested but payload was null');
return;
}
// Try to wait for providers to be ready to build rich content.
final ready = await _waitUntilReady(timeout: const Duration(seconds: 5));
BuildContext? ctx = _navigatorKey?.currentContext;
SupplementProvider? provider;
if (ready && ctx != null) {
provider = Provider.of<SupplementProvider>(ctx, listen: false);
}
String title = 'Supplement reminder';
String body = 'Tap to see details';
bool isSingle = false;
// Start with a mutable copy of the payload to add meta information
final Map<String, dynamic> mutablePayload = Map.from(payload);
final type = mutablePayload['type'];
if (type == 'single') {
final id = payload['id'];
isSingle = true;
// Ensure the payload for single snooze is correctly formatted
mutablePayload['type'] = 'single';
mutablePayload['id'] = id;
if (id is int && provider != null) {
Supplement? s;
try {
s = provider.supplements.firstWhere((el) => el.id == id);
} catch (_) {
s = null;
}
if (s != null) {
title = 'Time for ${s.name}';
body =
'${s.name}${s.numberOfUnits} ${s.unitType} (${s.ingredientsPerUnit})';
} else {
body = 'Tap to take supplement';
}
}
} else if (type == 'group') {
final timeKey = mutablePayload['time'];
if (timeKey is String) {
if (provider != null) {
final list = provider.supplements
.where((s) =>
s.isActive && s.reminderTimes.contains(timeKey))
.toList();
if (list.length == 1) {
final s = list.first;
isSingle = true;
title = 'Time for ${s.name}';
body =
'${s.name}${s.numberOfUnits} ${s.unitType} (${s.ingredientsPerUnit})';
// If a group becomes a single, update the payload type
mutablePayload['type'] = 'single';
mutablePayload['id'] = s.id;
mutablePayload.remove('time'); // Remove time key for single
} else if (list.isNotEmpty) {
isSingle = false;
title = 'Time for ${list.length} supplements';
final lines = list
.map((s) =>
'${s.name}${s.numberOfUnits} ${s.unitType} (${s.ingredientsPerUnit})')
.toList();
body = lines.join('\n');
// Ensure payload type is group
mutablePayload['type'] = 'group';
mutablePayload['time'] = timeKey;
} else {
// Fallback generic group
isSingle = false;
title = 'Supplement reminder';
body = 'Tap to see details';
mutablePayload['type'] = 'group';
mutablePayload['time'] = timeKey;
}
} else {
// Provider not ready; schedule generic group payload
isSingle = false;
mutablePayload['type'] = 'group';
mutablePayload['time'] = timeKey;
}
}
}
// Ensure the payload always has the correct type and ID/time for logging
// and re-scheduling. The SimpleNotificationService will add the 'meta' field.
final payloadStr = jsonEncode(mutablePayload);
await SimpleNotificationService.instance.scheduleOneOffReminder(
title: title,
body: body,
payload: payloadStr,
isSingle: isSingle,
delay: delay,
);
}
Future<bool> _waitUntilReady({required Duration timeout}) async {
final start = DateTime.now();
while (DateTime.now().difference(start) < timeout) {
final key = _navigatorKey;
final ctx = key?.currentContext;
if (ctx != null) {
try {
final provider = Provider.of<SupplementProvider>(ctx, listen: false);
if (!provider.isLoading) {
return true;
}
} catch (_) {
// Provider not available yet
}
}
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)),
);
}
}

View File

@@ -1,688 +1,11 @@
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; /*
import 'package:timezone/timezone.dart' as tz; Deprecated/removed: notification_service.dart
import 'package:timezone/data/latest.dart' as tz;
import '../models/supplement.dart';
import 'database_helper.dart';
// Top-level function to handle notification responses when app is running This legacy notification service has been intentionally removed.
@pragma('vm:entry-point') The app now uses a minimal scheduler in:
void notificationTapBackground(NotificationResponse notificationResponse) { services/simple_notification_service.dart
print('SupplementsLog: 📱 === BACKGROUND NOTIFICATION RESPONSE ===');
print('SupplementsLog: 📱 Action ID: ${notificationResponse.actionId}');
print('SupplementsLog: 📱 Payload: ${notificationResponse.payload}');
print('SupplementsLog: 📱 Notification ID: ${notificationResponse.id}');
print('SupplementsLog: 📱 ==========================================');
// For now, just log the action. The main app handler will process it. All retry/snooze/database-tracking logic has been dropped to keep things simple.
if (notificationResponse.actionId == 'take_supplement') { This file is left empty to ensure any lingering references fail at compile time,
print('SupplementsLog: 📱 BACKGROUND: Take action detected'); prompting migration to the new SimpleNotificationService.
} 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!');
}
// Mark notification as taken in database (this will cancel any pending retries)
if (notificationId != null) {
print('SupplementsLog: 📱 Marking notification $notificationId as taken');
await DatabaseHelper.instance.markNotificationTaken(notificationId);
// Cancel any pending retry notifications for this notification
_cancelRetryNotifications(notificationId);
}
// 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');
}
}

View File

@@ -0,0 +1,568 @@
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 'package:supplements/services/notification_debug_store.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({
DidReceiveBackgroundNotificationResponseCallback? onDidReceiveBackgroundNotificationResponse,
}) 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'),
DarwinNotificationAction.plain('snooze_single', 'Snooze'),
],
),
DarwinNotificationCategory(
'group',
actions: [
DarwinNotificationAction.plain('take_group', 'Take All'),
DarwinNotificationAction.plain('snooze_group', 'Snooze'),
],
),
],
);
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);
},
onDidReceiveBackgroundNotificationResponse: onDidReceiveBackgroundNotificationResponse,
);
_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;
// Tag payload with origin meta for debug/inspection
final Map<String, dynamic> payloadMap = isSingle
? {"type": "single", "id": items.first.id}
: {"type": "group", "time": timeKey};
payloadMap["meta"] = {"kind": "daily"};
final String payloadStr = jsonEncode(payloadMap);
final androidDetails = AndroidNotificationDetails(
_channelDailyId,
_channelDailyName,
channelDescription: _channelDailyDescription,
importance: Importance.high,
priority: Priority.high,
styleInformation: BigTextStyleInformation(
body,
contentTitle: title,
htmlFormatContentTitle: false,
),
actions: [
if (isSingle)
AndroidNotificationAction(
'take_single',
'Take',
showsUserInterface: true,
cancelNotification: true,
)
else
AndroidNotificationAction(
'take_group',
'Take All',
showsUserInterface: true,
cancelNotification: true,
),
if (isSingle)
AndroidNotificationAction(
'snooze_single',
'Snooze',
showsUserInterface: false,
// Removed cancelNotification: true for debugging
)
else
AndroidNotificationAction(
'snooze_group',
'Snooze',
showsUserInterface: false,
// Removed cancelNotification: true for debugging
),
],
);
final iosDetails = DarwinNotificationDetails(
categoryIdentifier: isSingle ? 'single' : 'group',
);
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,
);
// Log to debug store
final createdAtMs = DateTime.now().millisecondsSinceEpoch;
await NotificationDebugStore.instance.add(
NotificationLogEntry(
id: id,
kind: 'daily',
type: isSingle ? 'single' : 'group',
whenEpochMs: when.millisecondsSinceEpoch,
createdAtEpochMs: createdAtMs,
title: title,
payload: payloadStr,
singleId: isSingle ? items.first.id as int? : null,
timeKey: isSingle ? null : timeKey,
),
);
printLog('✅ Scheduled group $timeKey with ID $id');
}
// 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 iOSs 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 tomorrows 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 todays 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();
}
/// Cancel a specific notification by ID.
Future<void> cancelById(int id) async {
if (!_initialized) {
await initialize();
}
await _plugin.cancel(id);
}
/// Show an immediate notification. Useful for quick diagnostics.
Future<void> showInstant({
required String title,
required String body,
String? payload,
bool includeSnoozeActions = false, // New parameter
bool isSingle = true, // New parameter, defaults to single for instant
}) async {
if (!_initialized) {
await initialize();
}
final androidDetails = AndroidNotificationDetails(
'instant_notifications',
'Instant Notifications',
channelDescription: 'One-off or immediate notifications',
importance: Importance.high,
priority: Priority.high,
actions: includeSnoozeActions
? [
if (isSingle)
AndroidNotificationAction(
'take_single',
'Take',
showsUserInterface: true,
cancelNotification: true,
)
else
AndroidNotificationAction(
'take_group',
'Take All',
showsUserInterface: true,
cancelNotification: true,
),
if (isSingle)
AndroidNotificationAction(
'snooze_single',
'Snooze',
showsUserInterface: false,
cancelNotification: true,
)
else
AndroidNotificationAction(
'snooze_group',
'Snooze',
showsUserInterface: false,
cancelNotification: true,
),
]
: [], // No actions by default
);
final iosDetails = DarwinNotificationDetails(
categoryIdentifier: includeSnoozeActions
? (isSingle ? 'single' : 'group')
: null, // Use category for actions
);
await _plugin.show(
DateTime.now().millisecondsSinceEpoch ~/ 1000,
title,
body,
NotificationDetails(
android: androidDetails,
iOS: iosDetails,
),
payload: payload,
);
}
/// Schedule a one-off (non-repeating) reminder, typically used for Snooze.
Future<void> scheduleOneOffReminder({
required String title,
required String body,
required String payload,
required bool isSingle,
required Duration delay,
}) async {
if (!_initialized) {
await initialize();
}
final when = tz.TZDateTime.now(tz.local).add(delay);
final id = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final androidDetails = AndroidNotificationDetails(
_channelDailyId,
_channelDailyName,
channelDescription: _channelDailyDescription,
importance: Importance.high,
priority: Priority.high,
styleInformation: BigTextStyleInformation(
body,
contentTitle: title,
htmlFormatContentTitle: false,
),
actions: [
if (isSingle)
AndroidNotificationAction(
'take_single',
'Take',
showsUserInterface: true,
cancelNotification: true,
)
else
AndroidNotificationAction(
'take_group',
'Take All',
showsUserInterface: true,
cancelNotification: true,
),
if (isSingle)
AndroidNotificationAction(
'snooze_single',
'Snooze',
showsUserInterface: false,
// Removed cancelNotification: true for debugging
)
else
AndroidNotificationAction(
'snooze_group',
'Snooze',
showsUserInterface: false,
// Removed cancelNotification: true for debugging
),
],
);
final iosDetails = DarwinNotificationDetails(
categoryIdentifier: isSingle ? 'single' : 'group',
);
// Enrich payload with meta for snooze; also capture linkage for logging
Map<String, dynamic>? pmap;
try {
pmap = jsonDecode(payload) as Map<String, dynamic>;
} catch (_) {
pmap = null;
}
final createdAtMs = DateTime.now().millisecondsSinceEpoch;
String payloadFinal = payload;
int? logSingleId;
String? logTimeKey;
if (pmap != null) {
final meta = {
'kind': 'snooze',
'createdAt': createdAtMs,
'delayMin': delay.inMinutes,
};
pmap['meta'] = meta;
if (pmap['type'] == 'single') {
final v = pmap['id'];
logSingleId = v is int ? v : null;
} else if (pmap['type'] == 'group') {
logTimeKey = pmap['time'] as String?;
}
payloadFinal = jsonEncode(pmap);
}
await _plugin.zonedSchedule(
id,
title,
isSingle ? body : 'Tap to see details',
when,
NotificationDetails(
android: androidDetails,
iOS: iosDetails,
),
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
payload: payloadFinal,
);
// Log to debug store
await NotificationDebugStore.instance.add(
NotificationLogEntry(
id: id,
kind: 'snooze',
type: isSingle ? 'single' : 'group',
whenEpochMs: when.millisecondsSinceEpoch,
createdAtEpochMs: createdAtMs,
title: title,
payload: payloadFinal,
singleId: logSingleId,
timeKey: logTimeKey,
),
);
printLog('⏰ Scheduled one-off reminder (id=$id) at $when, isSingle=$isSingle');
}
Future<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;
}
}

View File

@@ -0,0 +1,206 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/supplement.dart';
import '../../providers/supplement_provider.dart';
/// Shows a bulk "Take supplements" dialog for a list of supplements.
/// - No time selection (records as now)
/// - Allows editing units per supplement
/// - Optional shared notes (applies to all)
Future<void> showBulkTakeDialog(
BuildContext context,
List<Supplement> supplements,
) async {
if (supplements.isEmpty) {
return;
}
// Controllers for each supplement's units
final Map<int, TextEditingController> unitControllers = {
for (final s in supplements.where((s) => s.id != null))
s.id!: TextEditingController(text: s.numberOfUnits.toString()),
};
final notesController = TextEditingController();
await showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text('Take ${supplements.length} supplements'),
content: SizedBox(
width: double.maxFinite,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 500),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// List of supplements with editable units
// Use a scroll view with explicit max height to avoid intrinsic dimension issues.
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 320),
child: SingleChildScrollView(
child: Column(
children: [
for (int index = 0; index < supplements.length; index++) ...[
() {
final s = supplements[index];
final controller = unitControllers[s.id] ??
TextEditingController(text: s.numberOfUnits.toString());
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Name and per-unit info
Row(
children: [
Expanded(
child: Text(
s.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 8),
Text(
s.unitType,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 8),
// Units editor
Row(
children: [
Expanded(
child: TextField(
controller: controller,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
labelText: 'Units',
border: const OutlineInputBorder(),
suffixText: s.unitType,
),
),
),
],
),
const SizedBox(height: 8),
// Dosage line
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Per unit:',
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
Text(
s.ingredientsDisplay,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
],
),
),
],
),
);
}(),
if (index != supplements.length - 1) const SizedBox(height: 8),
],
],
),
),
),
const SizedBox(height: 12),
// Shared notes
TextField(
controller: notesController,
decoration: const InputDecoration(
labelText: 'Notes for all (optional)',
border: OutlineInputBorder(),
),
maxLines: 2,
),
],
),
)),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {
final provider = context.read<SupplementProvider>();
int recorded = 0;
for (final s in supplements) {
if (s.id == null) continue;
final controller = unitControllers[s.id]!;
final units = double.tryParse(controller.text) ?? s.numberOfUnits.toDouble();
// totalDosageTaken stays 0.0 for now (ingredients-based tracking later)
provider.recordIntake(
s.id!,
0.0,
unitsTaken: units,
notes: notesController.text.isNotEmpty ? notesController.text : null,
takenAt: null, // "now"
);
recorded++;
}
Navigator.of(context).pop();
if (recorded > 0) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Recorded $recorded supplement${recorded == 1 ? '' : 's'}'),
backgroundColor: Colors.green,
),
);
}
},
child: const Text('Record All'),
),
],
);
},
);
// Dispose controllers
for (final c in unitControllers.values) {
c.dispose();
}
notesController.dispose();
}

View File

@@ -0,0 +1,277 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/supplement.dart';
import '../../providers/supplement_provider.dart';
/// Shows the "Take supplement" dialog.
/// - If [hideTime] is true, the time selection UI is hidden and intake is recorded as "now".
Future<void> showTakeSupplementDialog(
BuildContext context,
Supplement supplement, {
bool hideTime = false,
}) async {
final unitsController = TextEditingController(text: supplement.numberOfUnits.toString());
final notesController = TextEditingController();
DateTime selectedDateTime = DateTime.now();
bool useCustomTime = false;
await showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
title: Text('Take ${supplement.name}'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: TextField(
controller: unitsController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
labelText: 'Number of ${supplement.unitType}',
border: const OutlineInputBorder(),
suffixText: supplement.unitType,
),
onChanged: (value) => setState(() {}),
),
),
],
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Total dosage:',
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
Text(
supplement.ingredientsDisplay,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
],
),
),
const SizedBox(height: 16),
if (!hideTime) ...[
// Time selection section
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.access_time,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 6),
Text(
'When did you take it?',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: RadioListTile<bool>(
dense: true,
contentPadding: EdgeInsets.zero,
title: const Text('Just now', style: TextStyle(fontSize: 12)),
value: false,
groupValue: useCustomTime,
onChanged: (value) => setState(() => useCustomTime = value!),
),
),
Expanded(
child: RadioListTile<bool>(
dense: true,
contentPadding: EdgeInsets.zero,
title: const Text('Custom time', style: TextStyle(fontSize: 12)),
value: true,
groupValue: useCustomTime,
onChanged: (value) => setState(() => useCustomTime = value!),
),
),
],
),
if (useCustomTime) ...[
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.5),
),
),
child: Column(
children: [
// Date picker
Row(
children: [
Icon(
Icons.calendar_today,
size: 14,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Date: ${selectedDateTime.day}/${selectedDateTime.month}/${selectedDateTime.year}',
style: const TextStyle(fontSize: 12),
),
),
TextButton(
onPressed: () async {
final date = await showDatePicker(
context: context,
initialDate: selectedDateTime,
firstDate: DateTime.now().subtract(const Duration(days: 7)),
lastDate: DateTime.now(),
);
if (date != null) {
setState(() {
selectedDateTime = DateTime(
date.year,
date.month,
date.day,
selectedDateTime.hour,
selectedDateTime.minute,
);
});
}
},
child: const Text('Change', style: TextStyle(fontSize: 10)),
),
],
),
const SizedBox(height: 4),
// Time picker
Row(
children: [
Icon(
Icons.access_time,
size: 14,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Time: ${selectedDateTime.hour.toString().padLeft(2, '0')}:${selectedDateTime.minute.toString().padLeft(2, '0')}',
style: const TextStyle(fontSize: 12),
),
),
TextButton(
onPressed: () async {
final time = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(selectedDateTime),
);
if (time != null) {
setState(() {
selectedDateTime = DateTime(
selectedDateTime.year,
selectedDateTime.month,
selectedDateTime.day,
time.hour,
time.minute,
);
});
}
},
child: const Text('Change', style: TextStyle(fontSize: 10)),
),
],
),
],
),
),
],
],
),
),
const SizedBox(height: 16),
],
TextField(
controller: notesController,
decoration: const InputDecoration(
labelText: 'Notes (optional)',
border: OutlineInputBorder(),
),
maxLines: 2,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {
final unitsTaken = double.tryParse(unitsController.text) ?? supplement.numberOfUnits.toDouble();
// For now, we'll record 0 as total dosage since we're transitioning to ingredients
// This will be properly implemented when we add the full ingredient tracking
final totalDosageTaken = 0.0;
context.read<SupplementProvider>().recordIntake(
supplement.id!,
totalDosageTaken,
unitsTaken: unitsTaken,
notes: notesController.text.isNotEmpty ? notesController.text : null,
takenAt: hideTime
? null
: (useCustomTime ? selectedDateTime : null),
);
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${supplement.name} recorded!'),
backgroundColor: Colors.green,
),
);
},
child: const Text('Record'),
),
],
);
},
),
);
}

View File

@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
class InfoChip extends StatelessWidget {
final IconData icon;
final String label;
final BuildContext context;
final bool fullWidth;
const InfoChip({
required this.icon,
required this.label,
required this.context,
this.fullWidth = false,
});
@override
Widget build(BuildContext context) {
return Container(
width: fullWidth ? double.infinity : null,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.4),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: fullWidth ? MainAxisSize.max : MainAxisSize.min,
children: [
Icon(
icon,
size: 14,
color: Theme.of(context).colorScheme.outline,
),
const SizedBox(width: 4),
Flexible(
child: Text(
label,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.outline,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}

View File

@@ -9,6 +9,7 @@ class SupplementCard extends StatefulWidget {
final VoidCallback onEdit; final VoidCallback onEdit;
final VoidCallback onDelete; final VoidCallback onDelete;
final VoidCallback onArchive; final VoidCallback onArchive;
final VoidCallback onDuplicate;
const SupplementCard({ const SupplementCard({
super.key, super.key,
@@ -17,6 +18,7 @@ class SupplementCard extends StatefulWidget {
required this.onEdit, required this.onEdit,
required this.onDelete, required this.onDelete,
required this.onArchive, required this.onArchive,
required this.onDuplicate,
}); });
@override @override
@@ -175,7 +177,7 @@ class _SupplementCardState extends State<SupplementCard> {
), ),
), ),
ElevatedButton( ElevatedButton(
onPressed: isCompletelyTaken ? null : widget.onTake, onPressed: widget.onTake,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: isCompletelyTaken backgroundColor: isCompletelyTaken
? Colors.green.shade500 ? Colors.green.shade500
@@ -209,6 +211,9 @@ class _SupplementCardState extends State<SupplementCard> {
case 'edit': case 'edit':
widget.onEdit(); widget.onEdit();
break; break;
case 'duplicate':
widget.onDuplicate();
break;
case 'archive': case 'archive':
widget.onArchive(); widget.onArchive();
break; break;
@@ -218,6 +223,18 @@ class _SupplementCardState extends State<SupplementCard> {
} }
}, },
itemBuilder: (context) => [ itemBuilder: (context) => [
if (isTakenToday)
PopupMenuItem(
value: 'take',
onTap: widget.onTake,
child: const Row(
children: [
Icon(Icons.add_circle_outline),
SizedBox(width: 8),
Text('Take Again'),
],
),
),
const PopupMenuItem( const PopupMenuItem(
value: 'edit', value: 'edit',
child: Row( child: Row(
@@ -228,6 +245,16 @@ class _SupplementCardState extends State<SupplementCard> {
], ],
), ),
), ),
const PopupMenuItem(
value: 'duplicate',
child: Row(
children: [
Icon(Icons.copy),
SizedBox(width: 8),
Text('Duplicate'),
],
),
),
const PopupMenuItem( const PopupMenuItem(
value: 'archive', value: 'archive',
child: Row( child: Row(
@@ -434,7 +461,7 @@ class _SupplementCardState extends State<SupplementCard> {
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: isCompletelyTaken ? null : widget.onTake, onPressed: widget.onTake,
icon: Icon( icon: Icon(
isCompletelyTaken ? Icons.check_circle : Icons.medication, isCompletelyTaken ? Icons.check_circle : Icons.medication,
size: 18, size: 18,

View File

@@ -7,9 +7,13 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h> #include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
} }

View File

@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_linux flutter_secure_storage_linux
url_launcher_linux
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@@ -11,6 +11,7 @@ import flutter_secure_storage_darwin
import path_provider_foundation import path_provider_foundation
import shared_preferences_foundation import shared_preferences_foundation
import sqflite_darwin import sqflite_darwin
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
@@ -19,4 +20,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
} }

View File

@@ -1,6 +1,14 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
alarm:
dependency: "direct main"
description:
name: alarm
sha256: "1eef91f0b803a2370137e0dada9c7c24cc31edf4f1c30b06442dcf486cc192e0"
url: "https://pub.dev"
source: hosted
version: "5.1.4"
args: args:
dependency: transitive dependency: transitive
description: description:
@@ -113,6 +121,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.1" version: "2.1.1"
equatable:
dependency: transitive
description:
name: equatable
sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7"
url: "https://pub.dev"
source: hosted
version: "2.0.7"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -150,6 +166,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_fgbg:
dependency: transitive
description:
name: flutter_fgbg
sha256: eb6da9b2047372566a6e17b505975fe5bace94af01f6fc825c4b6f81baa6c447
url: "https://pub.dev"
source: hosted
version: "0.7.1"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -273,6 +297,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.20.2" version: "0.20.2"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
url: "https://pub.dev"
source: hosted
version: "4.9.0"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -305,6 +337,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" version: "6.0.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@@ -441,6 +481,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.5+1" version: "6.1.5+1"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
shared_preferences: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -638,6 +686,70 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "69ee86740f2847b9a4ba6cffa74ed12ce500bbe2b07f3dc1e643439da60637b7"
url: "https://pub.dev"
source: hosted
version: "6.3.18"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7
url: "https://pub.dev"
source: hosted
version: "6.3.4"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f
url: "https://pub.dev"
source: hosted
version: "3.2.3"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
url: "https://pub.dev"
source: hosted
version: "3.1.4"
uuid: uuid:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@@ -1,7 +1,7 @@
name: supplements name: supplements
description: "A supplement tracking app for managing your daily supplements" description: "A supplement tracking app for managing your daily supplements"
publish_to: "none" publish_to: "none"
version: 1.0.4+27082025 version: 1.0.8+30082025
environment: environment:
sdk: ^3.9.0 sdk: ^3.9.0
@@ -35,6 +35,8 @@ dependencies:
flutter_secure_storage: ^10.0.0-beta.4 flutter_secure_storage: ^10.0.0-beta.4
uuid: ^4.5.1 uuid: ^4.5.1
crypto: ^3.0.6 crypto: ^3.0.6
url_launcher: ^6.3.2
alarm: ^5.1.4
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@@ -8,10 +8,13 @@
#include <connectivity_plus/connectivity_plus_windows_plugin.h> #include <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h> #include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
ConnectivityPlusWindowsPluginRegisterWithRegistrar( ConnectivityPlusWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar( FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
} }

View File

@@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus connectivity_plus
flutter_secure_storage_windows flutter_secure_storage_windows
url_launcher_windows
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST