25 Commits

Author SHA1 Message Date
5684a197e7 chore: bump version to 1.0.10+05092025 2025-09-05 15:14:15 +02:00
7828e48d9d adds retry functionality 2025-09-05 15:13:55 +02:00
99711d56ec chore: bump version to 1.0.9+31082025 2025-08-31 20:12:39 +02:00
6bd80780e6 feat: Enhance HistoryScreen with month picker and improved intake display 2025-08-31 20:12:29 +02:00
7c63eb473b feat: Update theme colors and add Today Schedule screen
- Changed color scheme from ShadBlueColorScheme to ShadZincColorScheme in main.dart
- Added getMostRecentIntake method in SupplementProvider to retrieve the latest intake for a supplement
- Integrated TodayScheduleScreen into HomeScreen with a new BottomNavigationBar item
- Updated SupplementsListScreen title and adjusted layout for better UX
- Enhanced SupplementCard to support undoing last taken action and improved popover menu options
- Added popover package for better UI interactions
2025-08-31 20:00:32 +02:00
666008f05d Refactor and enhance UI components across multiple screens
- Updated date and time formatting in debug notifications screen for clarity.
- Wrapped context-dependent state updates in post-frame callbacks in history screen to ensure proper context usage.
- Improved layout and styling in settings screen by reordering radio list tiles.
- Enhanced logging in auto sync service for better error tracking.
- Added context mounted checks in notification router to prevent errors during navigation.
- Updated bulk take dialog to use new UI components from shadcn_ui package.
- Refactored take supplement dialog to utilize shadcn_ui for a more modern look and feel.
- Adjusted info chip and supplement card widgets to use updated color schemes and layouts.
- Updated pubspec.yaml and pubspec.lock to include new dependencies and versions.
2025-08-31 19:15:32 +02:00
6a2085f4e6 refactor: update color opacity methods to use withValues for consistency 2025-08-31 18:32:57 +02:00
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
40e7cc0461 chore: bump version to 1.0.4+27082025 2025-08-27 21:47:49 +02:00
e95dcf3322 feat: adds auto sync feature and fixes UI a bit up 2025-08-27 21:47:24 +02:00
33dfd6e3e5 chore: bump version to 1.0.3+27082025 2025-08-27 20:52:41 +02:00
2017fd097d feat adds proper syncing feature
Signed-off-by: Menno van Leeuwen <menno@vleeuwen.me>
2025-08-27 20:51:29 +02:00
b0d5130cbf chore: bump version to 1.0.2+27082025 2025-08-27 16:52:43 +02:00
bd459e0f1d chore: bump version to 1.0.1+27082025 2025-08-27 16:51:32 +02:00
709cf2cbd9 adds syncing 2025-08-27 16:17:21 +02:00
1191d06e53 adds license and proper readme 2025-08-27 14:11:05 +02:00
46 changed files with 7018 additions and 3510 deletions

14
.kilocode/mcp.json Normal file
View File

@@ -0,0 +1,14 @@
{
"mcpServers": {
"context7": {
"command": "npx",
"args": [
"-y",
"@upstash/context7-mcp"
],
"env": {
"DEFAULT_MINIMUM_TOKENS": ""
}
}
}
}

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"]
}
]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Menno van Leeuwen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,16 +1,74 @@
# supplements
# Supplements Tracker
A new Flutter project.
A Flutter application designed to help you track your daily supplement intake, manage dosages, and set reminders. This app aims to provide a simple and intuitive way to stay on top of your supplement regimen.
## Features
- **Supplement Management**: Easily add, edit, and delete supplements, including details like name, dosage, and frequency.
- **Intake Tracking**: Log your daily supplement intake, marking when you've taken your doses.
- **Reminders**: Set up local notifications to remind you when it's time to take your supplements.
- **Customizable Theme**: Switch between light and dark themes to suit your preference.
- **Local Data Storage**: All your supplement data is stored locally on your device.
## Technologies Used
- **Flutter**: The UI toolkit used for building natively compiled applications for mobile, web, and desktop from a single codebase.
- **Provider**: A robust state management solution for Flutter applications.
- **sqflite**: For efficient local database persistence using SQLite.
- **shared_preferences**: To persist user settings and preferences locally.
- **flutter_local_notifications**: For scheduling and displaying local notifications to remind you about dosages.
- **timezone**: Ensures accurate time zone handling for notification scheduling.
- **intl**: For internationalization and flexible date/time formatting.
## Getting Started
This project is a starting point for a Flutter application.
Follow these instructions to get a copy of the project up and running on your local machine for development and testing purposes.
A few resources to get you started if this is your first Flutter project:
### Prerequisites
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
Before you begin, ensure you have the following installed:
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
- **Flutter SDK**: Refer to the [official Flutter installation guide](https://flutter.dev/docs/get-started/install) for detailed instructions.
- An Integrated Development Environment (IDE) such as [VS Code](https://code.visualstudio.com/) with the Flutter extension, or [Android Studio](https://developer.android.com/studio).
### Installation
1. **Clone the repository**:
```bash
git clone https://github.com/vleeuwenmenno/supplements.git
cd supplements
```
2. **Get dependencies**:
Navigate to the project directory and run:
```bash
flutter pub get
```
### Running the Application
To run the application on a connected device or emulator:
```bash
flutter run
```
To build a release version for a specific platform:
- **Android APK**:
```bash
flutter build apk --release
```
- **iOS (requires macOS and Xcode)**:
```bash
flutter build ios --release
```
## Contributing
Contributions are welcome! If you have suggestions for improvements or new features, please feel free to open an issue or submit a pull request.
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

View File

@@ -4,12 +4,20 @@
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.USE_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
android:label="supplements"
android:label="My Supplements"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:enableOnBackInvokedCallback="true">
<service android:name="com.gdelataillade.alarm.services.NotificationOnKillService" />
<activity
android:name=".MainActivity"
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,44 +1,108 @@
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 'providers/supplement_provider.dart';
import 'providers/settings_provider.dart';
import 'screens/home_screen.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:supplements/logging.dart';
void main() {
runApp(const MyApp());
import 'providers/settings_provider.dart';
import 'providers/simple_sync_provider.dart';
import 'providers/supplement_provider.dart';
import 'screens/home_screen.dart';
import 'services/notification_router.dart';
import 'services/simple_notification_service.dart';
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
// 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 {
const MyApp({super.key});
final SettingsProvider settingsProvider;
const MyApp({super.key, required this.settingsProvider});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider(
create: (context) => SupplementProvider()..initialize(),
create: (context) => SupplementProvider()..initialize(context),
),
ChangeNotifierProvider.value(
value: settingsProvider,
),
ChangeNotifierProvider(
create: (context) => SettingsProvider()..initialize(),
create: (context) => SimpleSyncProvider(),
),
],
child: Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return MaterialApp(
child: Consumer2<SettingsProvider, SimpleSyncProvider>(
builder: (context, settingsProvider, syncProvider, child) {
// Set up the sync completion callback to refresh supplement data
// and initialize auto-sync integration
WidgetsBinding.instance.addPostFrameCallback((_) {
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
syncProvider.setOnSyncCompleteCallback(() async {
if (kDebugMode) {
printLog('Sync completed, refreshing UI data...');
}
await supplementProvider.loadSupplements();
await supplementProvider.loadTodayIntakes();
if (kDebugMode) {
printLog('UI data refreshed after sync');
}
});
// Initialize auto-sync service
syncProvider.initializeAutoSync(settingsProvider);
// Set up auto-sync callback for data changes
supplementProvider.setOnDataChangedCallback(() {
syncProvider.triggerAutoSyncIfEnabled();
});
});
return ShadApp(
navigatorKey: navigatorKey,
title: 'Supplements Tracker',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.light,
),
useMaterial3: true,
theme: ShadThemeData(
brightness: Brightness.light,
colorScheme: const ShadZincColorScheme.light(),
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.dark,
),
useMaterial3: true,
darkTheme: ShadThemeData(
brightness: Brightness.dark,
colorScheme: const ShadZincColorScheme.dark(),
),
themeMode: settingsProvider.themeMode,
home: const HomeScreen(),

View File

@@ -1,14 +1,26 @@
import '../services/database_sync_service.dart';
class Ingredient {
final int? id;
final String name; // e.g., "Vitamin K2", "Vitamin D3"
final double amount; // e.g., 75, 20
final String unit; // e.g., "mcg", "mg", "IU"
// Sync metadata
final String syncId;
final DateTime lastModified;
final RecordSyncStatus syncStatus;
final bool isDeleted;
const Ingredient({
this.id,
required this.name,
required this.amount,
required this.unit,
required this.syncId,
required this.lastModified,
this.syncStatus = RecordSyncStatus.pending,
this.isDeleted = false,
});
Map<String, dynamic> toMap() {
@@ -17,6 +29,10 @@ class Ingredient {
'name': name,
'amount': amount,
'unit': unit,
'syncId': syncId,
'lastModified': lastModified.toIso8601String(),
'syncStatus': syncStatus.name,
'isDeleted': isDeleted ? 1 : 0,
};
}
@@ -26,6 +42,17 @@ class Ingredient {
name: map['name'],
amount: map['amount']?.toDouble() ?? 0.0,
unit: map['unit'],
syncId: map['syncId'] ?? '',
lastModified: map['lastModified'] != null
? DateTime.parse(map['lastModified'])
: DateTime.now(),
syncStatus: map['syncStatus'] != null
? RecordSyncStatus.values.firstWhere(
(e) => e.name == map['syncStatus'],
orElse: () => RecordSyncStatus.pending,
)
: RecordSyncStatus.pending,
isDeleted: (map['isDeleted'] ?? 0) == 1,
);
}
@@ -34,12 +61,20 @@ class Ingredient {
String? name,
double? amount,
String? unit,
String? syncId,
DateTime? lastModified,
RecordSyncStatus? syncStatus,
bool? isDeleted,
}) {
return Ingredient(
id: id ?? this.id,
name: name ?? this.name,
amount: amount ?? this.amount,
unit: unit ?? this.unit,
syncId: syncId ?? this.syncId,
lastModified: lastModified ?? this.lastModified,
syncStatus: syncStatus ?? this.syncStatus,
isDeleted: isDeleted ?? this.isDeleted,
);
}
@@ -54,11 +89,12 @@ class Ingredient {
return other is Ingredient &&
other.name == name &&
other.amount == amount &&
other.unit == unit;
other.unit == unit &&
other.syncId == syncId;
}
@override
int get hashCode {
return name.hashCode ^ amount.hashCode ^ unit.hashCode;
return name.hashCode ^ amount.hashCode ^ unit.hashCode ^ syncId.hashCode;
}
}

View File

@@ -1,6 +1,10 @@
import 'ingredient.dart';
import 'dart:convert';
import 'package:uuid/uuid.dart';
import '../services/database_sync_service.dart';
import 'ingredient.dart';
class Supplement {
final int? id;
final String name;
@@ -14,6 +18,12 @@ class Supplement {
final DateTime createdAt;
final bool isActive;
// Sync metadata
final String syncId;
final DateTime lastModified;
final RecordSyncStatus syncStatus;
final bool isDeleted;
Supplement({
this.id,
required this.name,
@@ -26,7 +36,12 @@ class Supplement {
this.notes,
required this.createdAt,
this.isActive = true,
});
String? syncId,
DateTime? lastModified,
this.syncStatus = RecordSyncStatus.pending,
this.isDeleted = false,
}) : syncId = syncId ?? const Uuid().v4(),
lastModified = lastModified ?? DateTime.now();
// Helper getters
double get totalDosagePerIntake {
@@ -54,8 +69,7 @@ class Supplement {
}
Map<String, dynamic> toMap() {
return {
'id': id,
final map = <String, dynamic>{
'name': name,
'brand': brand,
'ingredients': jsonEncode(ingredients.map((ingredient) => ingredient.toMap()).toList()),
@@ -66,7 +80,17 @@ class Supplement {
'notes': notes,
'createdAt': createdAt.toIso8601String(),
'isActive': isActive ? 1 : 0,
'syncId': syncId,
'lastModified': lastModified.toIso8601String(),
'syncStatus': syncStatus.name,
'isDeleted': isDeleted ? 1 : 0,
};
if (id != null) {
map['id'] = id;
}
return map;
}
factory Supplement.fromMap(Map<String, dynamic> map) {
@@ -98,11 +122,23 @@ class Supplement {
notes: map['notes'],
createdAt: DateTime.parse(map['createdAt']),
isActive: map['isActive'] == 1,
syncId: map['syncId'] ?? const Uuid().v4(),
lastModified: map['lastModified'] != null
? DateTime.parse(map['lastModified'])
: DateTime.now(),
syncStatus: map['syncStatus'] != null
? RecordSyncStatus.values.firstWhere(
(e) => e.name == map['syncStatus'],
orElse: () => RecordSyncStatus.pending,
)
: RecordSyncStatus.pending,
isDeleted: (map['isDeleted'] ?? 0) == 1,
);
}
Supplement copyWith({
int? id,
bool setNullId = false,
String? name,
String? brand,
List<Ingredient>? ingredients,
@@ -113,9 +149,14 @@ class Supplement {
String? notes,
DateTime? createdAt,
bool? isActive,
String? syncId,
bool newSyncId = false,
DateTime? lastModified,
RecordSyncStatus? syncStatus,
bool? isDeleted,
}) {
return Supplement(
id: id ?? this.id,
id: setNullId ? null : (id ?? this.id),
name: name ?? this.name,
brand: brand ?? this.brand,
ingredients: ingredients ?? this.ingredients,
@@ -126,6 +167,34 @@ class Supplement {
notes: notes ?? this.notes,
createdAt: createdAt ?? this.createdAt,
isActive: isActive ?? this.isActive,
syncId: newSyncId ? null : (syncId ?? this.syncId),
lastModified: lastModified ?? this.lastModified,
syncStatus: syncStatus ?? this.syncStatus,
isDeleted: isDeleted ?? this.isDeleted,
);
}
/// Create a new supplement with updated sync status and timestamp
Supplement markAsModified() {
return copyWith(
lastModified: DateTime.now(),
syncStatus: RecordSyncStatus.modified,
);
}
/// Create a new supplement marked as synced
Supplement markAsSynced() {
return copyWith(
syncStatus: RecordSyncStatus.synced,
);
}
/// Create a new supplement marked for deletion
Supplement markAsDeleted() {
return copyWith(
isDeleted: true,
lastModified: DateTime.now(),
syncStatus: RecordSyncStatus.modified,
);
}
}

View File

@@ -1,3 +1,7 @@
import 'package:uuid/uuid.dart';
import '../services/database_sync_service.dart';
class SupplementIntake {
final int? id;
final int supplementId;
@@ -6,6 +10,12 @@ class SupplementIntake {
final double unitsTaken; // Number of units taken (can be fractional)
final String? notes;
// Sync metadata
final String syncId;
final DateTime lastModified;
final RecordSyncStatus syncStatus;
final bool isDeleted;
SupplementIntake({
this.id,
required this.supplementId,
@@ -13,7 +23,12 @@ class SupplementIntake {
required this.dosageTaken,
required this.unitsTaken,
this.notes,
});
String? syncId,
DateTime? lastModified,
this.syncStatus = RecordSyncStatus.pending,
this.isDeleted = false,
}) : syncId = syncId ?? const Uuid().v4(),
lastModified = lastModified ?? DateTime.now();
Map<String, dynamic> toMap() {
return {
@@ -23,6 +38,10 @@ class SupplementIntake {
'dosageTaken': dosageTaken,
'unitsTaken': unitsTaken,
'notes': notes,
'syncId': syncId,
'lastModified': lastModified.toIso8601String(),
'syncStatus': syncStatus.name,
'isDeleted': isDeleted ? 1 : 0,
};
}
@@ -34,6 +53,17 @@ class SupplementIntake {
dosageTaken: map['dosageTaken'],
unitsTaken: (map['unitsTaken'] ?? 1).toDouble(), // Default for backwards compatibility
notes: map['notes'],
syncId: map['syncId'] ?? const Uuid().v4(),
lastModified: map['lastModified'] != null
? DateTime.parse(map['lastModified'])
: DateTime.now(),
syncStatus: map['syncStatus'] != null
? RecordSyncStatus.values.firstWhere(
(e) => e.name == map['syncStatus'],
orElse: () => RecordSyncStatus.pending,
)
: RecordSyncStatus.pending,
isDeleted: (map['isDeleted'] ?? 0) == 1,
);
}
@@ -44,6 +74,10 @@ class SupplementIntake {
double? dosageTaken,
double? unitsTaken,
String? notes,
String? syncId,
DateTime? lastModified,
RecordSyncStatus? syncStatus,
bool? isDeleted,
}) {
return SupplementIntake(
id: id ?? this.id,
@@ -52,6 +86,26 @@ class SupplementIntake {
dosageTaken: dosageTaken ?? this.dosageTaken,
unitsTaken: unitsTaken ?? this.unitsTaken,
notes: notes ?? this.notes,
syncId: syncId ?? this.syncId,
lastModified: lastModified ?? this.lastModified,
syncStatus: syncStatus ?? this.syncStatus,
isDeleted: isDeleted ?? this.isDeleted,
);
}
/// Create a new intake marked as synced
SupplementIntake markAsSynced() {
return copyWith(
syncStatus: RecordSyncStatus.synced,
);
}
/// Create a new intake marked for deletion
SupplementIntake markAsDeleted() {
return copyWith(
isDeleted: true,
lastModified: DateTime.now(),
syncStatus: RecordSyncStatus.modified,
);
}
}

View File

@@ -20,10 +20,17 @@ class SettingsProvider extends ChangeNotifier {
int _nightStart = 23;
int _nightEnd = 4;
// Persistent reminder settings
bool _persistentReminders = true;
int _reminderRetryInterval = 5; // minutes
int _maxRetryAttempts = 3;
// Notifications
int _snoozeMinutes = 10;
// Notification retry settings
bool _notificationRetryEnabled = true;
int _notificationRetryCount = 3;
int _notificationRetryDelayMinutes = 5;
// Auto-sync settings
bool _autoSyncEnabled = false;
int _autoSyncDebounceSeconds = 5;
ThemeOption get themeOption => _themeOption;
@@ -37,10 +44,17 @@ class SettingsProvider extends ChangeNotifier {
int get nightStart => _nightStart;
int get nightEnd => _nightEnd;
// Persistent reminder getters
bool get persistentReminders => _persistentReminders;
int get reminderRetryInterval => _reminderRetryInterval;
int get maxRetryAttempts => _maxRetryAttempts;
// Notifications
int get snoozeMinutes => _snoozeMinutes;
// Notification retry getters
bool get notificationRetryEnabled => _notificationRetryEnabled;
int get notificationRetryCount => _notificationRetryCount;
int get notificationRetryDelayMinutes => _notificationRetryDelayMinutes;
// Auto-sync getters
bool get autoSyncEnabled => _autoSyncEnabled;
int get autoSyncDebounceSeconds => _autoSyncDebounceSeconds;
// Helper method to get formatted time ranges for display
String get morningRange => '${_formatHour(_morningStart)} - ${_formatHour((_morningEnd + 1) % 24)}';
@@ -78,10 +92,17 @@ class SettingsProvider extends ChangeNotifier {
_nightStart = prefs.getInt('night_start') ?? 23;
_nightEnd = prefs.getInt('night_end') ?? 4;
// Load persistent reminder settings
_persistentReminders = prefs.getBool('persistent_reminders') ?? true;
_reminderRetryInterval = prefs.getInt('reminder_retry_interval') ?? 5;
_maxRetryAttempts = prefs.getInt('max_retry_attempts') ?? 3;
// Load snooze setting
_snoozeMinutes = prefs.getInt('snooze_minutes') ?? 10;
// Load notification retry settings
_notificationRetryEnabled = prefs.getBool('notification_retry_enabled') ?? true;
_notificationRetryCount = prefs.getInt('notification_retry_count') ?? 3;
_notificationRetryDelayMinutes = prefs.getInt('notification_retry_delay_minutes') ?? 5;
// Load auto-sync settings
_autoSyncEnabled = prefs.getBool('auto_sync_enabled') ?? false;
_autoSyncDebounceSeconds = prefs.getInt('auto_sync_debounce_seconds') ?? 30;
notifyListeners();
}
@@ -94,6 +115,14 @@ class SettingsProvider extends ChangeNotifier {
await prefs.setInt('theme_option', option.index);
}
Future<void> setDateOfBirthAndGender(DateTime dateOfBirth, String gender) async {
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setString('date_of_birth', dateOfBirth.toIso8601String());
await prefs.setString('gender', gender);
}
Future<void> setTimeRanges({
required int morningStart,
required int morningEnd,
@@ -232,28 +261,64 @@ class SettingsProvider extends ChangeNotifier {
}
}
// Persistent reminder setters
Future<void> setPersistentReminders(bool enabled) async {
_persistentReminders = enabled;
// Notifications setters
Future<void> setSnoozeMinutes(int minutes) async {
const allowed = [5, 10, 15, 20];
if (!allowed.contains(minutes)) {
throw ArgumentError('Snooze minutes must be one of ${allowed.join(", ")}');
}
_snoozeMinutes = minutes;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('persistent_reminders', enabled);
await prefs.setInt('snooze_minutes', minutes);
}
Future<void> setReminderRetryInterval(int minutes) async {
_reminderRetryInterval = minutes;
Future<void> setNotificationRetryEnabled(bool enabled) async {
_notificationRetryEnabled = enabled;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('reminder_retry_interval', minutes);
await prefs.setBool('notification_retry_enabled', enabled);
}
Future<void> setMaxRetryAttempts(int attempts) async {
_maxRetryAttempts = attempts;
Future<void> setNotificationRetryCount(int count) async {
if (count < 0 || count > 10) {
throw ArgumentError('Retry count must be between 0 and 10');
}
_notificationRetryCount = count;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('max_retry_attempts', attempts);
await prefs.setInt('notification_retry_count', count);
}
Future<void> setNotificationRetryDelayMinutes(int minutes) async {
const allowed = [1, 2, 3, 5, 10, 15, 20, 30];
if (!allowed.contains(minutes)) {
throw ArgumentError('Retry delay must be one of ${allowed.join(", ")} minutes');
}
_notificationRetryDelayMinutes = minutes;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('notification_retry_delay_minutes', minutes);
}
// Auto-sync setters
Future<void> setAutoSyncEnabled(bool enabled) async {
_autoSyncEnabled = enabled;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('auto_sync_enabled', enabled);
}
Future<void> setAutoSyncDebounceSeconds(int seconds) async {
_autoSyncDebounceSeconds = seconds;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('auto_sync_debounce_seconds', seconds);
}
}

View File

@@ -0,0 +1,171 @@
import 'package:flutter/foundation.dart';
import 'package:supplements/logging.dart';
import '../services/database_sync_service.dart';
import '../services/auto_sync_service.dart';
import 'settings_provider.dart';
class SimpleSyncProvider with ChangeNotifier {
final DatabaseSyncService _syncService = DatabaseSyncService();
// Callback for UI refresh after sync
VoidCallback? _onSyncCompleteCallback;
// Auto-sync service
AutoSyncService? _autoSyncService;
// Track if current sync is auto-triggered
bool _isAutoSync = false;
// Getters
SyncStatus get status => _syncService.status;
String? get lastError => _syncService.lastError;
DateTime? get lastSyncTime => _syncService.lastSyncTime;
bool get isConfigured => _syncService.isConfigured;
bool get isSyncing => status == SyncStatus.downloading ||
status == SyncStatus.merging ||
status == SyncStatus.uploading;
bool get isAutoSync => _isAutoSync;
AutoSyncService? get autoSyncService => _autoSyncService;
// Auto-sync error handling getters
bool get isAutoSyncDisabledDueToErrors => _autoSyncService?.isAutoDisabledDueToErrors ?? false;
int get autoSyncConsecutiveFailures => _autoSyncService?.consecutiveFailures ?? 0;
String? get autoSyncLastError => _autoSyncService?.lastErrorMessage;
bool get hasAutoSyncScheduledRetry => _autoSyncService?.hasScheduledRetry ?? false;
// Configuration getters
String? get serverUrl => _syncService.serverUrl;
String? get username => _syncService.username;
String? get password => _syncService.password;
String? get remotePath => _syncService.remotePath;
SimpleSyncProvider() {
// Set up callbacks to notify listeners
_syncService.onStatusChanged = (_) => notifyListeners();
_syncService.onError = (_) => notifyListeners();
_syncService.onSyncCompleted = () {
notifyListeners();
// Trigger UI refresh callback if set
_onSyncCompleteCallback?.call();
};
// Load saved configuration and notify listeners when done
_loadConfiguration();
}
/// Set callback to refresh UI data after sync completes
void setOnSyncCompleteCallback(VoidCallback? callback) {
_onSyncCompleteCallback = callback;
}
/// Initialize auto-sync service with settings provider
void initializeAutoSync(SettingsProvider settingsProvider) {
_autoSyncService = AutoSyncService(
syncProvider: this,
settingsProvider: settingsProvider,
);
if (kDebugMode) {
printLog('SimpleSyncProvider: Auto-sync service initialized');
}
}
/// Triggers auto-sync if enabled and configured
void triggerAutoSyncIfEnabled() {
_autoSyncService?.triggerAutoSync();
}
Future<void> _loadConfiguration() async {
await _syncService.loadSavedConfiguration();
notifyListeners(); // Notify UI that configuration might be available
}
Future<void> configure({
required String serverUrl,
required String username,
required String password,
required String remotePath,
}) async {
_syncService.configure(
serverUrl: serverUrl,
username: username,
password: password,
remotePath: remotePath,
);
notifyListeners();
}
Future<bool> testConnection() async {
return await _syncService.testConnection();
}
Future<void> syncDatabase({bool isAutoSync = false}) async {
if (!isConfigured) {
throw Exception('Sync not configured');
}
_isAutoSync = isAutoSync;
notifyListeners();
try {
await _syncService.syncDatabase();
} catch (e) {
if (kDebugMode) {
printLog('Sync failed in provider: $e');
}
rethrow;
} finally {
_isAutoSync = false;
notifyListeners();
}
}
void clearError() {
_syncService.clearError();
notifyListeners();
}
/// Resets auto-sync error state and re-enables auto-sync if it was disabled
void resetAutoSyncErrors() {
_autoSyncService?.resetErrorState();
notifyListeners();
}
String getStatusText() {
final syncType = _isAutoSync ? 'Auto-sync' : 'Sync';
// Check for auto-sync specific errors first
if (isAutoSyncDisabledDueToErrors) {
return 'Auto-sync disabled due to repeated failures. ${autoSyncLastError ?? 'Check sync settings.'}';
}
switch (status) {
case SyncStatus.idle:
if (hasAutoSyncScheduledRetry) {
return 'Auto-sync will retry shortly...';
}
return 'Ready to sync';
case SyncStatus.downloading:
return '$syncType: Downloading remote database...';
case SyncStatus.merging:
return '$syncType: Merging databases...';
case SyncStatus.uploading:
return '$syncType: Uploading database...';
case SyncStatus.completed:
return '$syncType completed successfully';
case SyncStatus.error:
// For auto-sync errors, show more specific messages
if (_isAutoSync && autoSyncLastError != null) {
return 'Auto-sync failed: $autoSyncLastError';
}
return '$syncType failed: ${lastError ?? 'Unknown error'}';
}
}
@override
void dispose() {
_autoSyncService?.dispose();
super.dispose();
}
}

View File

@@ -1,61 +1,70 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.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 '../models/supplement_intake.dart';
import '../providers/settings_provider.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 {
final DatabaseHelper _databaseHelper = DatabaseHelper.instance;
final NotificationService _notificationService = NotificationService();
final SimpleNotificationService _notificationService = SimpleNotificationService.instance;
bool _initialized = false;
List<Supplement> _supplements = [];
List<Map<String, dynamic>> _todayIntakes = [];
List<Map<String, dynamic>> _monthlyIntakes = [];
bool _isLoading = false;
Timer? _persistentReminderTimer;
Timer? _dateChangeTimer;
DateTime _lastDateCheck = DateTime.now();
// Callback for triggering sync when data changes
VoidCallback? _onDataChanged;
// Context for accessing other providers
BuildContext? _context;
List<Supplement> get supplements => _supplements;
List<Map<String, dynamic>> get todayIntakes => _todayIntakes;
List<Map<String, dynamic>> get monthlyIntakes => _monthlyIntakes;
bool get isLoading => _isLoading;
Future<void> initialize() async {
/// Set callback for triggering sync when data changes
void setOnDataChangedCallback(VoidCallback? callback) {
_onDataChanged = callback;
}
/// Trigger sync if callback is set
void _triggerSyncIfEnabled() {
_onDataChanged?.call();
}
Future<void> initialize([BuildContext? context]) async {
if (_initialized) {
return;
}
_initialized = true;
_context = context;
// Add this provider as an observer for app lifecycle changes
WidgetsBinding.instance.addObserver(this);
await _notificationService.initialize();
// Set up the callback for handling supplement intake from notifications
print('📱 Setting up notification callback...');
_notificationService.setTakeSupplementCallback((supplementId, supplementName, units, unitType) {
print('📱 === NOTIFICATION CALLBACK TRIGGERED ===');
print('📱 Supplement ID: $supplementId');
print('📱 Supplement Name: $supplementName');
print('📱 Units: $units');
print('📱 Unit Type: $unitType');
// Record the intake when user taps "Take" on notification
recordIntake(supplementId, 0.0, unitsTaken: units);
print('📱 Intake recorded successfully');
print('📱 === CALLBACK COMPLETE ===');
if (kDebugMode) {
print('📱 Recorded intake from notification: $supplementName ($units $unitType)');
}
});
print('📱 Notification callback setup complete');
// Request permissions with error handling
try {
await _notificationService.requestPermissions();
} catch (e) {
if (kDebugMode) {
print('Error requesting notification permissions: $e');
printLog('Error requesting notification permissions: $e');
}
// Continue without notifications rather than crashing
}
@@ -63,35 +72,14 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
await loadSupplements();
await loadTodayIntakes();
// Reschedule notifications for all active supplements to ensure persistence
// Schedule notifications for all active supplements
await _rescheduleAllNotifications();
// Start periodic checking for persistent reminders (every 5 minutes)
_startPersistentReminderCheck();
// Start date change monitoring to reset daily intake status
_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('Error checking persistent reminders: $e');
}
}
});
// Also check immediately
_checkPersistentReminders();
}
void _startDateChangeMonitoring() {
// Cancel any existing timer
@@ -105,8 +93,8 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
if (currentDate != lastCheckDate) {
if (kDebugMode) {
print('Date changed detected: ${lastCheckDate} -> ${currentDate}');
print('Refreshing today\'s intakes for new day...');
printLog('Date changed detected: $lastCheckDate -> $currentDate');
printLog('Refreshing today\'s intakes for new day...');
}
// Date has changed, refresh today's intakes
@@ -114,49 +102,22 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
await loadTodayIntakes();
if (kDebugMode) {
print('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('📱 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('📱 🔄 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('📱 🚨 MANUAL TRIGGER: Forcing retry check...');
await checkPersistentRemindersWithSettings(
persistentReminders: true,
reminderRetryInterval: 5, // Force 5 minute interval for testing
maxRetryAttempts: 3,
);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_persistentReminderTimer?.cancel();
_dateChangeTimer?.cancel();
super.dispose();
}
@@ -168,7 +129,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
if (state == AppLifecycleState.resumed) {
// App came back to foreground, check if date changed
if (kDebugMode) {
print('App resumed, checking for date change...');
printLog('App resumed, checking for date change...');
}
forceCheckDateChange();
}
@@ -176,23 +137,34 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
Future<void> _rescheduleAllNotifications() async {
if (kDebugMode) {
print('📱 Rescheduling notifications for all active supplements...');
printLog('📱 Rescheduling notifications for all active supplements...');
}
for (final supplement in _supplements) {
if (supplement.reminderTimes.isNotEmpty) {
try {
SettingsProvider? settingsProvider;
if (_context != null && _context!.mounted) {
try {
await _notificationService.scheduleSupplementReminders(supplement);
settingsProvider = Provider.of<SettingsProvider>(_context!, listen: false);
} catch (e) {
if (kDebugMode) {
print('📱 Error rescheduling notifications for ${supplement.name}: $e');
printLog('📱 Could not access SettingsProvider: $e');
}
}
}
await _notificationService.scheduleDailyGroupedRemindersSafe(
_supplements,
settingsProvider: settingsProvider,
);
await _notificationService.getPendingNotifications();
} catch (e) {
if (kDebugMode) {
printLog('📱 Error scheduling grouped notifications: $e');
}
}
if (kDebugMode) {
print('📱 Finished rescheduling notifications');
printLog('📱 Finished rescheduling notifications');
}
}
@@ -201,16 +173,16 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
notifyListeners();
try {
print('Loading supplements from database...');
printLog('Loading supplements from database...');
_supplements = await _databaseHelper.getAllSupplements();
print('Loaded ${_supplements.length} supplements');
printLog('Loaded ${_supplements.length} supplements');
for (var supplement in _supplements) {
print('Supplement: ${supplement.name}');
printLog('Supplement: ${supplement.name}');
}
} catch (e) {
print('Error loading supplements: $e');
printLog('Error loading supplements: $e');
if (kDebugMode) {
print('Error loading supplements: $e');
printLog('Error loading supplements: $e');
}
} finally {
_isLoading = false;
@@ -220,25 +192,20 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
Future<void> addSupplement(Supplement supplement) async {
try {
print('Adding supplement: ${supplement.name}');
printLog('Adding supplement: ${supplement.name}');
final id = await _databaseHelper.insertSupplement(supplement);
print('Supplement inserted with ID: $id');
final newSupplement = supplement.copyWith(id: id);
// Schedule notifications (skip if there's an error)
try {
await _notificationService.scheduleSupplementReminders(newSupplement);
print('Notifications scheduled');
} catch (notificationError) {
print('Warning: Could not schedule notifications: $notificationError');
}
printLog('Supplement inserted with ID: $id');
await loadSupplements();
print('Supplements reloaded, count: ${_supplements.length}');
printLog('Supplements reloaded, count: ${_supplements.length}');
await _rescheduleAllNotifications();
// Trigger sync after adding supplement
_triggerSyncIfEnabled();
} catch (e) {
print('Error adding supplement: $e');
printLog('Error adding supplement: $e');
if (kDebugMode) {
print('Error adding supplement: $e');
printLog('Error adding supplement: $e');
}
rethrow;
}
@@ -248,13 +215,36 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
try {
await _databaseHelper.updateSupplement(supplement);
// Reschedule notifications
await _notificationService.scheduleSupplementReminders(supplement);
await loadSupplements();
await _rescheduleAllNotifications();
// Trigger sync after updating supplement
_triggerSyncIfEnabled();
} catch (e) {
if (kDebugMode) {
print('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');
}
}
}
@@ -263,13 +253,14 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
try {
await _databaseHelper.deleteSupplement(id);
// Cancel notifications
await _notificationService.cancelSupplementReminders(id);
await loadSupplements();
await _rescheduleAllNotifications();
// Trigger sync after deleting supplement
_triggerSyncIfEnabled();
} catch (e) {
if (kDebugMode) {
print('Error deleting supplement: $e');
printLog('Error deleting supplement: $e');
}
}
}
@@ -287,16 +278,19 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
await _databaseHelper.insertIntake(intake);
await loadTodayIntakes();
// Trigger sync after recording intake
_triggerSyncIfEnabled();
// Show confirmation notification
final supplement = _supplements.firstWhere((s) => s.id == supplementId);
final unitsText = unitsTaken != null && unitsTaken != 1 ? '${unitsTaken.toStringAsFixed(unitsTaken % 1 == 0 ? 0 : 1)} ${supplement.unitType}' : '';
await _notificationService.showInstantNotification(
'Supplement Taken',
'Recorded ${supplement.name}${unitsText.isNotEmpty ? ' - $unitsText' : ''} (${supplement.ingredientsDisplay})',
await _notificationService.showInstant(
title: 'Supplement Taken',
body: 'Recorded ${supplement.name}${unitsText.isNotEmpty ? ' - $unitsText' : ''} (${supplement.ingredientsDisplay})',
);
} catch (e) {
if (kDebugMode) {
print('Error recording intake: $e');
printLog('Error recording intake: $e');
}
}
}
@@ -305,22 +299,22 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
try {
final today = DateTime.now();
if (kDebugMode) {
print('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);
if (kDebugMode) {
print('Loaded ${_todayIntakes.length} intakes for today');
printLog('Loaded ${_todayIntakes.length} intakes for today');
for (var intake in _todayIntakes) {
print(' - Supplement ID: ${intake['supplement_id']}, taken at: ${intake['takenAt']}');
printLog(' - Supplement ID: ${intake['supplement_id']}, taken at: ${intake['takenAt']}');
}
}
notifyListeners();
} catch (e) {
if (kDebugMode) {
print('Error loading today\'s intakes: $e');
printLog('Error loading today\'s intakes: $e');
}
}
}
@@ -331,7 +325,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
notifyListeners();
} catch (e) {
if (kDebugMode) {
print('Error loading monthly intakes: $e');
printLog('Error loading monthly intakes: $e');
}
}
}
@@ -341,7 +335,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
return await _databaseHelper.getIntakesWithSupplementsForDate(date);
} catch (e) {
if (kDebugMode) {
print('Error loading intakes for date: $e');
printLog('Error loading intakes for date: $e');
}
return [];
}
@@ -356,9 +350,31 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
await loadMonthlyIntakes(DateTime.now().year, DateTime.now().month);
}
notifyListeners();
// Trigger sync after deleting intake
_triggerSyncIfEnabled();
} catch (e) {
if (kDebugMode) {
print('Error deleting intake: $e');
printLog('Error deleting intake: $e');
}
}
}
Future<void> permanentlyDeleteIntake(int intakeId) async {
try {
await _databaseHelper.permanentlyDeleteIntake(intakeId);
await loadTodayIntakes();
// Also refresh monthly intakes if they're loaded
if (_monthlyIntakes.isNotEmpty) {
await loadMonthlyIntakes(DateTime.now().year, DateTime.now().month);
}
notifyListeners();
// Trigger sync after permanently deleting intake
_triggerSyncIfEnabled();
} catch (e) {
if (kDebugMode) {
printLog('Error permanently deleting intake: $e');
}
}
}
@@ -371,10 +387,44 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
return _todayIntakes.where((intake) => intake['supplement_id'] == supplementId).length;
}
/// Get the most recent intake for a supplement today
Map<String, dynamic>? getMostRecentIntake(int supplementId) {
final supplementIntakes = _todayIntakes
.where((intake) => intake['supplement_id'] == supplementId)
.toList();
if (supplementIntakes.isEmpty) return null;
// Sort by takenAt time (most recent first)
supplementIntakes.sort((a, b) {
final aTime = DateTime.parse(a['takenAt']);
final bTime = DateTime.parse(b['takenAt']);
return bTime.compareTo(aTime); // Descending order
});
return supplementIntakes.first;
}
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)
Future<void> refreshDailyStatus() async {
if (kDebugMode) {
print('Manually refreshing daily status...');
printLog('Manually refreshing daily status...');
}
_lastDateCheck = DateTime.now();
await loadTodayIntakes();
@@ -387,20 +437,20 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
final lastCheckDate = DateTime(_lastDateCheck.year, _lastDateCheck.month, _lastDateCheck.day);
if (kDebugMode) {
print('Force checking date change...');
print('Current date: $currentDate');
print('Last check date: $lastCheckDate');
printLog('Force checking date change...');
printLog('Current date: $currentDate');
printLog('Last check date: $lastCheckDate');
}
if (currentDate != lastCheckDate) {
if (kDebugMode) {
print('Date change detected, refreshing intakes...');
printLog('Date change detected, refreshing intakes...');
}
_lastDateCheck = now;
await loadTodayIntakes();
} else {
if (kDebugMode) {
print('No date change detected');
printLog('No date change detected');
}
}
}
@@ -415,7 +465,7 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
notifyListeners();
} catch (e) {
if (kDebugMode) {
print('Error loading archived supplements: $e');
printLog('Error loading archived supplements: $e');
}
}
}
@@ -425,9 +475,12 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
await _databaseHelper.archiveSupplement(supplementId);
await loadSupplements(); // Refresh active supplements
await loadArchivedSupplements(); // Refresh archived supplements
// Trigger sync after archiving supplement
_triggerSyncIfEnabled();
} catch (e) {
if (kDebugMode) {
print('Error archiving supplement: $e');
printLog('Error archiving supplement: $e');
}
}
}
@@ -437,45 +490,58 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
await _databaseHelper.unarchiveSupplement(supplementId);
await loadSupplements(); // Refresh active supplements
await loadArchivedSupplements(); // Refresh archived supplements
// Trigger sync after unarchiving supplement
_triggerSyncIfEnabled();
} catch (e) {
if (kDebugMode) {
print('Error unarchiving supplement: $e');
printLog('Error unarchiving supplement: $e');
}
}
}
Future<void> deleteArchivedSupplement(int supplementId) async {
try {
await _databaseHelper.deleteSupplement(supplementId);
await _databaseHelper.permanentlyDeleteSupplement(supplementId);
await loadArchivedSupplements(); // Refresh archived supplements
// Trigger sync after permanently deleting archived supplement
_triggerSyncIfEnabled();
} catch (e) {
if (kDebugMode) {
print('Error deleting archived supplement: $e');
printLog('Error permanently deleting archived supplement: $e');
}
}
}
// Debug methods for notification testing
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 {
await _notificationService.testScheduledNotification();
await _notificationService.showInstant(
title: 'Test Scheduled Notification',
body: 'This is a simple test notification.',
);
}
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 {
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
Future<void> rescheduleAllNotifications() async {
@@ -484,6 +550,6 @@ class SupplementProvider with ChangeNotifier, WidgetsBindingObserver {
// Debug method to cancel all notifications
Future<void> cancelAllNotifications() async {
await _notificationService.cancelAllReminders();
await _notificationService.cancelAll();
}
}

View File

View File

@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/supplement.dart';
import 'package:uuid/uuid.dart';
import '../models/ingredient.dart';
import '../models/supplement.dart';
import '../providers/supplement_provider.dart';
// Helper class to manage ingredient text controllers
@@ -22,6 +24,8 @@ class IngredientController {
name: nameController.text.trim(),
amount: double.tryParse(amountController.text) ?? 0.0,
unit: selectedUnit,
syncId: const Uuid().v4(),
lastModified: DateTime.now(),
);
}
@@ -48,7 +52,7 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
final _notesController = TextEditingController();
// Multi-ingredient support with persistent controllers
List<IngredientController> _ingredientControllers = [];
final _ingredientControllers = [];
String _selectedUnitType = 'capsules';
int _frequencyPerDay = 1;
@@ -162,7 +166,7 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
Expanded(
flex: 1,
child: DropdownButtonFormField<String>(
value: controller.selectedUnit,
initialValue: controller.selectedUnit,
decoration: const InputDecoration(
labelText: 'Unit',
border: OutlineInputBorder(),
@@ -220,6 +224,13 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
appBar: AppBar(
title: Text(isEditing ? 'Edit Supplement' : 'Add Supplement'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: [
IconButton(
tooltip: isEditing ? 'Update Supplement' : 'Save Supplement',
onPressed: _saveSupplement,
icon: const Icon(Icons.save),
),
],
),
body: Form(
key: _formKey,
@@ -306,7 +317,7 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
Expanded(
flex: 1,
child: DropdownButtonFormField<String>(
value: _selectedUnitType,
initialValue: _selectedUnitType,
decoration: const InputDecoration(
labelText: 'Type',
border: OutlineInputBorder(),
@@ -331,7 +342,7 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
// Show ingredients summary
if (_ingredientControllers.isNotEmpty && _ingredientControllers.any((c) => c.nameController.text.isNotEmpty && (double.tryParse(c.amountController.text) ?? 0) > 0))
Card(
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3),
color: Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.3),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
@@ -478,17 +489,7 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
),
const SizedBox(height: 24),
// Save button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _saveSupplement,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(16),
),
child: Text(isEditing ? 'Update Supplement' : 'Add Supplement'),
),
),
// Save is now in the AppBar for consistency with app-wide pattern
],
),
),
@@ -556,19 +557,24 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
void _saveSupplement() async {
if (_formKey.currentState!.validate()) {
// Validate that we have at least one ingredient with name and amount
final validIngredients = _ingredientControllers.where((controller) =>
controller.nameController.text.trim().isNotEmpty &&
(double.tryParse(controller.amountController.text) ?? 0) > 0
).map((controller) => Ingredient(
name: controller.nameController.text.trim(),
amount: double.tryParse(controller.amountController.text) ?? 0,
unit: controller.selectedUnit,
)).toList();
final validIngredients = _ingredientControllers
.where((controller) =>
controller.nameController.text.trim().isNotEmpty &&
(double.tryParse(controller.amountController.text) ?? 0) > 0)
.map((controller) => Ingredient(
name: controller.nameController.text.trim(),
amount: double.tryParse(controller.amountController.text) ?? 0.0,
unit: controller.selectedUnit,
syncId: const Uuid().v4(),
lastModified: DateTime.now(),
))
.toList();
if (validIngredients.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
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;
@@ -577,14 +583,20 @@ class _AddSupplementScreenState extends State<AddSupplementScreen> {
final supplement = Supplement(
id: widget.supplement?.id,
name: _nameController.text.trim(),
brand: _brandController.text.trim().isNotEmpty ? _brandController.text.trim() : null,
brand: _brandController.text.trim().isNotEmpty
? _brandController.text.trim()
: null,
ingredients: validIngredients,
numberOfUnits: int.parse(_numberOfUnitsController.text),
unitType: _selectedUnitType,
frequencyPerDay: _frequencyPerDay,
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(),
syncId: widget.supplement?.syncId, // Preserve syncId on update
lastModified: DateTime.now(), // Always update lastModified on save
);
final provider = context.read<SupplementProvider>();

View File

@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/supplement_provider.dart';
import 'package:supplements/widgets/info_chip.dart';
import '../models/supplement.dart';
import '../providers/supplement_provider.dart';
class ArchivedSupplementsScreen extends StatefulWidget {
const ArchivedSupplementsScreen({super.key});
@@ -162,9 +164,9 @@ class _ArchivedSupplementCard extends StatelessWidget {
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3),
color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2),
width: 1,
),
),
@@ -178,7 +180,7 @@ class _ArchivedSupplementCard extends StatelessWidget {
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.outline.withOpacity(0.1),
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(
@@ -260,7 +262,7 @@ class _ArchivedSupplementCard extends StatelessWidget {
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3),
color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(8),
),
child: Column(
@@ -282,7 +284,7 @@ class _ArchivedSupplementCard extends StatelessWidget {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.outline.withOpacity(0.1),
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
),
child: Text(
@@ -305,13 +307,13 @@ class _ArchivedSupplementCard extends StatelessWidget {
// Dosage info
Row(
children: [
_InfoChip(
InfoChip(
icon: Icons.schedule,
label: '${supplement.frequencyPerDay}x daily',
context: context,
),
const SizedBox(width: 8),
_InfoChip(
InfoChip(
icon: Icons.medication,
label: '${supplement.numberOfUnits} ${supplement.unitType}',
context: context,
@@ -321,7 +323,7 @@ class _ArchivedSupplementCard extends StatelessWidget {
if (supplement.reminderTimes.isNotEmpty) ...[
const SizedBox(height: 8),
_InfoChip(
InfoChip(
icon: Icons.notifications_off,
label: 'Was: ${supplement.reminderTimes.join(', ')}',
context: context,
@@ -335,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,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../providers/settings_provider.dart';
import '../providers/supplement_provider.dart';
class HistoryScreen extends StatefulWidget {
@@ -13,12 +15,10 @@ class HistoryScreen extends StatefulWidget {
class _HistoryScreenState extends State<HistoryScreen> {
int _selectedMonth = DateTime.now().month;
int _selectedYear = DateTime.now().year;
DateTime? _selectedDay;
@override
void initState() {
super.initState();
_selectedDay = DateTime.now(); // Set today as the default selected day
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<SupplementProvider>().loadMonthlyIntakes(_selectedYear, _selectedMonth);
});
@@ -30,16 +30,33 @@ class _HistoryScreenState extends State<HistoryScreen> {
appBar: AppBar(
title: const Text('Intake History'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: [
IconButton(
icon: const Icon(Icons.calendar_today),
onPressed: _showMonthPicker,
tooltip: 'Select Month',
),
],
),
body: _buildCalendarView(),
body: _buildCompactHistoryView(),
);
}
Widget _buildCalendarView() {
Widget _buildCompactHistoryView() {
return Column(
children: [
// Month selector header
Container(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(
bottom: BorderSide(
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2),
width: 1,
),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -53,22 +70,19 @@ class _HistoryScreenState extends State<HistoryScreen> {
_selectedMonth--;
}
});
context.read<SupplementProvider>().loadMonthlyIntakes(_selectedYear, _selectedMonth);
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<SupplementProvider>().loadMonthlyIntakes(_selectedYear, _selectedMonth);
});
},
icon: const Icon(Icons.chevron_left),
iconSize: 20,
),
InkWell(
onTap: _showMonthPicker,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
DateFormat('MMMM yyyy').format(DateTime(_selectedYear, _selectedMonth)),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
Text(
DateFormat('MMMM yyyy').format(DateTime(_selectedYear, _selectedMonth)),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
IconButton(
@@ -83,70 +97,191 @@ class _HistoryScreenState extends State<HistoryScreen> {
_selectedMonth++;
}
});
context.read<SupplementProvider>().loadMonthlyIntakes(_selectedYear, _selectedMonth);
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<SupplementProvider>().loadMonthlyIntakes(_selectedYear, _selectedMonth);
});
}
},
icon: const Icon(Icons.chevron_right),
iconSize: 20,
),
],
),
),
// History list
Expanded(
child: Consumer<SupplementProvider>(
builder: (context, provider, child) {
// Group intakes by date
final groupedIntakes = <String, List<Map<String, dynamic>>>{};
for (final intake in provider.monthlyIntakes) {
final date = DateTime.parse(intake['takenAt']);
final dateKey = DateFormat('yyyy-MM-dd').format(date);
groupedIntakes.putIfAbsent(dateKey, () => []);
groupedIntakes[dateKey]!.add(intake);
if (provider.monthlyIntakes.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.history,
size: 48,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'No intake history for this month',
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
);
}
return LayoutBuilder(
builder: (context, constraints) {
final isWideScreen = constraints.maxWidth > 800;
// Sort intakes by date (most recent first)
final sortedIntakes = List<Map<String, dynamic>>.from(provider.monthlyIntakes)
..sort((a, b) => DateTime.parse(b['takenAt']).compareTo(DateTime.parse(a['takenAt'])));
if (isWideScreen) {
// Desktop/tablet layout: side-by-side
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Calendar on the left
Expanded(
flex: 2,
child: Container(
margin: const EdgeInsets.all(16),
child: _buildCalendar(groupedIntakes),
),
),
// Selected day details on the right
Expanded(
flex: 3,
child: Container(
margin: const EdgeInsets.fromLTRB(0, 16, 16, 16),
child: _buildSelectedDayDetails(groupedIntakes),
),
),
],
);
} else {
// Mobile layout: vertical stack
return Column(
children: [
// Calendar
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: sortedIntakes.length,
itemBuilder: (context, index) {
final intake = sortedIntakes[index];
final takenAt = DateTime.parse(intake['takenAt']);
final units = (intake['unitsTaken'] as num?)?.toDouble() ?? 1.0;
// Check if we need a date header
final previousIntake = index > 0 ? sortedIntakes[index - 1] : null;
final showDateHeader = previousIntake == null ||
DateFormat('yyyy-MM-dd').format(DateTime.parse(previousIntake['takenAt'])) !=
DateFormat('yyyy-MM-dd').format(takenAt);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showDateHeader) ...[
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
child: _buildCalendar(groupedIntakes),
),
const SizedBox(height: 16),
// Selected day details
Expanded(
child: _buildSelectedDayDetails(groupedIntakes),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
DateFormat('EEEE, MMMM d').format(takenAt),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
),
),
],
);
}
Dismissible(
key: Key('intake_${intake['id']}'),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
color: Colors.red.shade400,
child: const Icon(
Icons.delete,
color: Colors.white,
),
),
confirmDismiss: (direction) async {
return await showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Intake'),
content: Text('Delete ${intake['supplementName']} taken at ${DateFormat('HH:mm').format(takenAt)}?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Delete'),
),
],
),
);
},
onDismissed: (direction) {
context.read<SupplementProvider>().deleteIntake(intake['id']);
},
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
child: Card(
margin: EdgeInsets.zero,
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: InkWell(
onTap: () => _deleteIntake(context, intake['id'], intake['supplementName']),
borderRadius: BorderRadius.circular(12),
splashColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.4),
highlightColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(
Icons.medication,
color: Theme.of(context).colorScheme.primary,
size: 18,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
intake['supplementName'],
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 2),
Text(
'${DateFormat('HH:mm').format(takenAt)}${units.toStringAsFixed(units % 1 == 0 ? 0 : 1)} ${intake['supplementUnitType'] ?? 'units'}',
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
if (intake['notes'] != null && intake['notes'].toString().isNotEmpty) ...[
const SizedBox(height: 2),
Text(
intake['notes'],
style: TextStyle(
fontSize: 11,
fontStyle: FontStyle.italic,
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.8),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
Icon(
Icons.delete_outline,
size: 18,
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
),
],
),
),
),
),
),
),
],
);
},
);
},
@@ -179,12 +314,14 @@ class _HistoryScreenState extends State<HistoryScreen> {
);
if (picked != null) {
if (!context.mounted) return;
setState(() {
_selectedMonth = picked.month;
_selectedYear = picked.year;
_selectedDay = picked; // Set the selected day to the picked date
});
context.read<SupplementProvider>().loadMonthlyIntakes(_selectedYear, _selectedMonth);
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<SupplementProvider>().loadMonthlyIntakes(_selectedYear, _selectedMonth);
});
}
}
@@ -202,13 +339,16 @@ class _HistoryScreenState extends State<HistoryScreen> {
ElevatedButton(
onPressed: () async {
await context.read<SupplementProvider>().deleteIntake(intakeId);
if (!context.mounted) return;
Navigator.of(context).pop();
// Force refresh of the UI
setState(() {});
// Force refresh of the current view data
context.read<SupplementProvider>().loadMonthlyIntakes(_selectedYear, _selectedMonth);
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<SupplementProvider>().loadMonthlyIntakes(_selectedYear, _selectedMonth);
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -225,341 +365,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
);
}
Widget _buildCalendar(Map<String, List<Map<String, dynamic>>> groupedIntakes) {
final firstDayOfMonth = DateTime(_selectedYear, _selectedMonth, 1);
final lastDayOfMonth = DateTime(_selectedYear, _selectedMonth + 1, 0);
final firstWeekday = firstDayOfMonth.weekday;
final daysInMonth = lastDayOfMonth.day;
// Calculate how many cells we need (including empty ones for alignment)
final totalCells = ((daysInMonth + firstWeekday - 1) / 7).ceil() * 7;
final weeks = (totalCells / 7).ceil();
return LayoutBuilder(
builder: (context, constraints) {
final isWideScreen = constraints.maxWidth > 800;
// Calculate calendar height based on number of weeks needed
final cellHeight = isWideScreen ? 56.0 : 48.0;
final calendarContentHeight = (weeks * cellHeight) + 60; // +60 for headers and padding
final calendarHeight = isWideScreen ? 400.0 : calendarContentHeight;
return Card(
child: Container(
height: calendarHeight,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Calendar header (weekdays)
Row(
children: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
.map((day) => Expanded(
child: Center(
child: Text(
day,
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: isWideScreen ? 14 : 12,
),
),
),
))
.toList(),
),
const SizedBox(height: 8),
// Calendar grid
Expanded(
child: GridView.builder(
shrinkWrap: true,
physics: const ClampingScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 7,
childAspectRatio: 1.0,
mainAxisSpacing: 4,
crossAxisSpacing: 4,
),
itemCount: totalCells,
itemBuilder: (context, index) {
final dayNumber = index - firstWeekday + 2;
if (dayNumber < 1 || dayNumber > daysInMonth) {
return const SizedBox(); // Empty cell
}
final date = DateTime(_selectedYear, _selectedMonth, dayNumber);
final dateKey = DateFormat('yyyy-MM-dd').format(date);
final hasIntakes = groupedIntakes.containsKey(dateKey);
final intakeCount = hasIntakes ? groupedIntakes[dateKey]!.length : 0;
final isSelected = _selectedDay != null &&
DateFormat('yyyy-MM-dd').format(_selectedDay!) == dateKey;
final isToday = DateFormat('yyyy-MM-dd').format(DateTime.now()) == dateKey;
return GestureDetector(
onTap: () {
setState(() {
_selectedDay = date;
});
},
child: Container(
margin: const EdgeInsets.all(1),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.primary
: hasIntakes
? Theme.of(context).colorScheme.primaryContainer
: null,
border: isToday
? Border.all(color: Theme.of(context).colorScheme.secondary, width: 2)
: null,
borderRadius: BorderRadius.circular(8),
),
child: Stack(
children: [
Center(
child: Text(
'$dayNumber',
style: TextStyle(
color: isSelected
? Theme.of(context).colorScheme.onPrimary
: hasIntakes
? Theme.of(context).colorScheme.onPrimaryContainer
: Theme.of(context).colorScheme.onSurface,
fontWeight: isToday ? FontWeight.bold : FontWeight.normal,
fontSize: isWideScreen ? 16 : 14,
),
),
),
if (hasIntakes)
Positioned(
top: 2,
right: 2,
child: Container(
padding: EdgeInsets.all(isWideScreen ? 3 : 2),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(8),
),
constraints: BoxConstraints(
minWidth: isWideScreen ? 18 : 16,
minHeight: isWideScreen ? 18 : 16,
),
child: Text(
'$intakeCount',
style: TextStyle(
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onPrimary,
fontSize: isWideScreen ? 11 : 10,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
),
],
),
),
);
},
),
),
],
),
),
),
);
},
);
}
Widget _buildSelectedDayDetails(Map<String, List<Map<String, dynamic>>> groupedIntakes) {
return LayoutBuilder(
builder: (context, constraints) {
final isWideScreen = constraints.maxWidth > 600;
if (_selectedDay == null) {
return Card(
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.touch_app,
size: isWideScreen ? 64 : 48,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'Tap a date on the calendar to see details',
style: TextStyle(
fontSize: isWideScreen ? 18 : 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
),
),
);
}
final dateKey = DateFormat('yyyy-MM-dd').format(_selectedDay!);
final dayIntakes = groupedIntakes[dateKey] ?? [];
if (dayIntakes.isEmpty) {
return Card(
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.event_available,
size: isWideScreen ? 64 : 48,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'No supplements taken on ${DateFormat('MMM d, yyyy').format(_selectedDay!)}',
style: TextStyle(
fontSize: isWideScreen ? 18 : 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
),
),
);
}
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: double.infinity,
padding: EdgeInsets.all(isWideScreen ? 20 : 16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
DateFormat('EEEE, MMM d, yyyy').format(_selectedDay!),
style: TextStyle(
fontSize: isWideScreen ? 20 : 18,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
const SizedBox(height: 4),
Text(
'${dayIntakes.length} supplement${dayIntakes.length != 1 ? 's' : ''} taken',
style: TextStyle(
fontSize: isWideScreen ? 16 : 14,
color: Theme.of(context).colorScheme.onPrimaryContainer.withOpacity(0.8),
),
),
],
),
),
Expanded(
child: ListView.builder(
padding: EdgeInsets.all(isWideScreen ? 20 : 16),
itemCount: dayIntakes.length,
itemBuilder: (context, index) {
final intake = dayIntakes[index];
final takenAt = DateTime.parse(intake['takenAt']);
final units = (intake['unitsTaken'] as num?)?.toDouble() ?? 1.0;
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 2,
child: Padding(
padding: EdgeInsets.all(isWideScreen ? 16 : 12),
child: Row(
children: [
CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.primary,
radius: isWideScreen ? 24 : 20,
child: Icon(
Icons.medication,
color: Theme.of(context).colorScheme.onPrimary,
size: isWideScreen ? 24 : 20,
),
),
SizedBox(width: isWideScreen ? 16 : 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
intake['supplementName'],
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: isWideScreen ? 16 : 14,
),
),
const SizedBox(height: 4),
Text(
'${units.toStringAsFixed(units % 1 == 0 ? 0 : 1)} ${intake['supplementUnitType'] ?? 'units'} at ${DateFormat('HH:mm').format(takenAt)}',
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w500,
fontSize: isWideScreen ? 14 : 12,
),
),
if (intake['notes'] != null && intake['notes'].toString().isNotEmpty) ...[
const SizedBox(height: 4),
Text(
intake['notes'],
style: TextStyle(
fontSize: isWideScreen ? 13 : 12,
fontStyle: FontStyle.italic,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
],
),
),
IconButton(
icon: Icon(
Icons.delete_outline,
color: Colors.red.shade400,
size: isWideScreen ? 24 : 20,
),
onPressed: () => _deleteIntake(context, intake['id'], intake['supplementName']),
tooltip: 'Delete intake',
),
],
),
),
);
},
),
),
],
),
);
},
);
}
@override
void dispose() {

View File

@@ -1,11 +1,12 @@
import 'package:flutter/material.dart';
import 'package:provider/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 'history_screen.dart';
import 'settings_screen.dart';
import 'supplements_list_screen.dart';
import 'today_schedule_screen.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@@ -18,6 +19,7 @@ class _HomeScreenState extends State<HomeScreen> {
int _currentIndex = 0;
final List<Widget> _screens = [
const TodayScheduleScreen(),
const SupplementsListScreen(),
const HistoryScreen(),
const SettingsScreen(),
@@ -28,45 +30,10 @@ class _HomeScreenState extends State<HomeScreen> {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<SupplementProvider>().initialize();
_startPersistentReminderCheck();
});
}
void _startPersistentReminderCheck() {
// 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('📱 === HOME SCREEN: Checking persistent reminders ===');
final supplementProvider = context.read<SupplementProvider>();
final settingsProvider = context.read<SettingsProvider>();
print('📱 Settings: persistent=${settingsProvider.persistentReminders}, interval=${settingsProvider.reminderRetryInterval}, max=${settingsProvider.maxRetryAttempts}');
await supplementProvider.checkPersistentRemindersWithSettings(
persistentReminders: settingsProvider.persistentReminders,
reminderRetryInterval: settingsProvider.reminderRetryInterval,
maxRetryAttempts: settingsProvider.maxRetryAttempts,
);
print('📱 === HOME SCREEN: Persistent reminder check complete ===');
} catch (e) {
print('Error checking persistent reminders: $e');
}
}
// Persistent reminder checks removed
@override
Widget build(BuildContext context) {
@@ -80,11 +47,18 @@ class _HomeScreenState extends State<HomeScreen> {
});
},
type: BottomNavigationBarType.fixed,
selectedItemColor: Theme.of(context).colorScheme.primary,
unselectedItemColor: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4),
backgroundColor: Theme.of(context).colorScheme.surface,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.medication),
icon: Icon(Icons.medication_liquid_sharp),
label: 'Supplements',
),
BottomNavigationBarItem(
icon: Icon(Icons.edit_calendar_rounded),
label: 'Edit',
),
BottomNavigationBarItem(
icon: Icon(Icons.history),
label: 'History',
@@ -95,7 +69,7 @@ class _HomeScreenState extends State<HomeScreen> {
),
],
),
floatingActionButton: _currentIndex == 0
floatingActionButton: _currentIndex == 1
? FloatingActionButton(
onPressed: () async {
await Navigator.of(context).push(

View File

@@ -1,823 +1,58 @@
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});
@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('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
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
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
? const Center(child: CircularProgressIndicator())
: RefreshIndicator(
onRefresh: _loadNotifications,
child: _buildNotificationsList(),
),
);
}
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,
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.notifications_none,
size: 72,
color: Colors.grey,
),
),
SizedBox(height: 8),
Text(
'All caught up!',
style: TextStyle(
color: Colors.grey,
const SizedBox(height: 16),
Text(
'No pending notifications UI in simple mode',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
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),
Row(
children: [
const Icon(
Icons.medication,
size: 16,
color: Colors.grey,
),
const SizedBox(width: 8),
Text(
notification['supplementName'],
style: TextStyle(
color: Colors.grey.shade700,
fontStyle: FontStyle.italic,
Text(
'Notifications are now scheduled daily at the selected times '
'without retries or tracking.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey.shade600,
),
),
],
),
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,9 +1,10 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/settings_provider.dart';
import '../providers/supplement_provider.dart';
import '../services/notification_service.dart';
import 'pending_notifications_screen.dart';
import 'debug_notifications_screen.dart';
import 'simple_sync_settings_screen.dart';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@@ -19,6 +20,41 @@ class SettingsScreen extends StatelessWidget {
return ListView(
padding: const EdgeInsets.all(16.0),
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(
child: ListTile(
leading: const Icon(Icons.cloud_sync),
title: const Text('Cloud Sync'),
subtitle: const Text('Configure WebDAV sync settings'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const SimpleSyncSettingsScreen(),
),
);
},
),
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
@@ -31,8 +67,6 @@ class SettingsScreen extends StatelessWidget {
),
const SizedBox(height: 16),
RadioListTile<ThemeOption>(
title: const Text('Follow System'),
subtitle: const Text('Use system theme setting'),
value: ThemeOption.system,
groupValue: settingsProvider.themeOption,
onChanged: (value) {
@@ -40,10 +74,10 @@ class SettingsScreen extends StatelessWidget {
settingsProvider.setThemeOption(value);
}
},
title: const Text('Follow System'),
subtitle: const Text('Use system theme setting'),
),
RadioListTile<ThemeOption>(
title: const Text('Light Theme'),
subtitle: const Text('Always use light theme'),
value: ThemeOption.light,
groupValue: settingsProvider.themeOption,
onChanged: (value) {
@@ -51,10 +85,10 @@ class SettingsScreen extends StatelessWidget {
settingsProvider.setThemeOption(value);
}
},
title: const Text('Light Theme'),
subtitle: const Text('Always use light theme'),
),
RadioListTile<ThemeOption>(
title: const Text('Dark Theme'),
subtitle: const Text('Always use dark theme'),
value: ThemeOption.dark,
groupValue: settingsProvider.themeOption,
onChanged: (value) {
@@ -62,12 +96,110 @@ class SettingsScreen extends StatelessWidget {
settingsProvider.setThemeOption(value);
}
},
title: const Text('Dark Theme'),
subtitle: const Text('Always use dark theme'),
),
],
),
),
),
const SizedBox(height: 16),
// Notifications
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Notifications',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.snooze),
title: const Text('Snooze duration'),
subtitle: const Text('Delay for Snooze action'),
trailing: DropdownButton<int>(
value: settingsProvider.snoozeMinutes,
items: const [
DropdownMenuItem(value: 5, child: Text('5 min')),
DropdownMenuItem(value: 10, child: Text('10 min')),
DropdownMenuItem(value: 15, child: Text('15 min')),
DropdownMenuItem(value: 20, child: Text('20 min')),
],
onChanged: (value) {
if (value != null) {
settingsProvider.setSnoozeMinutes(value);
}
},
),
),
const Divider(),
SwitchListTile(
contentPadding: EdgeInsets.zero,
secondary: const Icon(Icons.repeat),
title: const Text('Notification Retries'),
subtitle: const Text('Automatically retry missed notifications'),
value: settingsProvider.notificationRetryEnabled,
onChanged: (value) {
settingsProvider.setNotificationRetryEnabled(value);
},
),
if (settingsProvider.notificationRetryEnabled) ...[
const SizedBox(height: 8),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.format_list_numbered),
title: const Text('Retry count'),
subtitle: const Text('Number of retry attempts'),
trailing: DropdownButton<int>(
value: settingsProvider.notificationRetryCount,
items: const [
DropdownMenuItem(value: 1, child: Text('1')),
DropdownMenuItem(value: 2, child: Text('2')),
DropdownMenuItem(value: 3, child: Text('3')),
DropdownMenuItem(value: 4, child: Text('4')),
DropdownMenuItem(value: 5, child: Text('5')),
],
onChanged: (value) {
if (value != null) {
settingsProvider.setNotificationRetryCount(value);
}
},
),
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.schedule),
title: const Text('Retry delay'),
subtitle: const Text('Time between retry attempts'),
trailing: DropdownButton<int>(
value: settingsProvider.notificationRetryDelayMinutes,
items: const [
DropdownMenuItem(value: 1, child: Text('1 min')),
DropdownMenuItem(value: 2, child: Text('2 min')),
DropdownMenuItem(value: 3, child: Text('3 min')),
DropdownMenuItem(value: 5, child: Text('5 min')),
DropdownMenuItem(value: 10, child: Text('10 min')),
DropdownMenuItem(value: 15, child: Text('15 min')),
DropdownMenuItem(value: 20, child: Text('20 min')),
DropdownMenuItem(value: 30, child: Text('30 min')),
],
onChanged: (value) {
if (value != null) {
settingsProvider.setNotificationRetryDelayMinutes(value);
}
},
),
),
],
],
),
),
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
@@ -137,354 +269,6 @@ class SettingsScreen extends StatelessWidget {
),
),
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.notifications_active, color: Colors.blue),
const SizedBox(width: 8),
Text(
'Persistent Reminders',
style: Theme.of(context).textTheme.titleMedium,
),
],
),
const SizedBox(height: 8),
Text(
'Configure automatic reminder retries for ignored notifications',
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'),
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),
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.notifications_outlined),
const SizedBox(width: 8),
Text(
'Notifications',
style: Theme.of(context).textTheme.titleMedium,
),
],
),
const SizedBox(height: 8),
Text(
'View and manage pending notifications',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const PendingNotificationsScreen(),
),
);
},
icon: const Icon(Icons.list),
label: const Text('View Pending Notifications'),
),
),
],
),
),
),
const SizedBox(height: 16),
if (Theme.of(context).brightness == Brightness.dark) // Only show in debug mode for now
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.bug_report, color: Colors.orange),
const SizedBox(width: 8),
Text(
'Debug - Notifications',
style: Theme.of(context).textTheme.titleMedium,
),
],
),
const SizedBox(height: 16),
Consumer<SupplementProvider>(
builder: (context, supplementProvider, child) {
return Column(
children: [
ElevatedButton.icon(
onPressed: () async {
await supplementProvider.testNotifications();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Test notification sent!')),
);
}
},
icon: const Icon(Icons.notifications_active),
label: const Text('Test Instant'),
),
const SizedBox(height: 8),
ElevatedButton.icon(
onPressed: () async {
await supplementProvider.testScheduledNotification();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Scheduled test notification for 1 minute from now!')),
);
}
},
icon: const Icon(Icons.schedule),
label: const Text('Test Scheduled (1min)'),
),
const SizedBox(height: 8),
ElevatedButton.icon(
onPressed: () async {
await supplementProvider.testNotificationActions();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Test notification with actions sent! Try the Take/Snooze buttons.')),
);
}
},
icon: const Icon(Icons.touch_app),
label: const Text('Test Actions'),
),
const SizedBox(height: 8),
ElevatedButton.icon(
onPressed: () async {
await NotificationService().testBasicNotification();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Basic test notification sent! Tap it to test callback.')),
);
}
},
icon: const Icon(Icons.tap_and_play),
label: const Text('Test Basic Tap'),
),
const SizedBox(height: 8),
ElevatedButton.icon(
onPressed: () async {
await supplementProvider.rescheduleAllNotifications();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('All notifications rescheduled!')),
);
}
},
icon: const Icon(Icons.refresh),
label: const Text('Reschedule All'),
),
const SizedBox(height: 8),
ElevatedButton.icon(
onPressed: () async {
await supplementProvider.cancelAllNotifications();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('All notifications cancelled!')),
);
}
},
icon: const Icon(Icons.cancel),
label: const Text('Cancel All'),
),
const SizedBox(height: 8),
ElevatedButton.icon(
onPressed: () async {
final pending = await supplementProvider.getPendingNotifications();
if (context.mounted) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Pending Notifications'),
content: pending.isEmpty
? const Text('No pending notifications')
: SizedBox(
width: double.maxFinite,
child: Consumer<SupplementProvider>(
builder: (context, provider, child) {
return ListView.builder(
shrinkWrap: true,
itemCount: pending.length,
itemBuilder: (context, index) {
final notification = pending[index];
// Calculate scheduled time inline
String scheduledTime = '';
try {
final notificationId = notification.id;
if (notificationId == 99999) {
scheduledTime = 'Test notification';
} else if (notificationId > 1000) {
final snoozeMinutes = notificationId % 1000;
scheduledTime = 'Snoozed ($snoozeMinutes min)';
} else {
final supplementId = notificationId ~/ 100;
final reminderIndex = notificationId % 100;
final supplement = provider.supplements.firstWhere(
(s) => s.id == supplementId,
orElse: () => provider.supplements.first,
);
if (reminderIndex < supplement.reminderTimes.length) {
final reminderTime = supplement.reminderTimes[reminderIndex];
final now = DateTime.now();
final timeParts = reminderTime.split(':');
final hour = int.parse(timeParts[0]);
final minute = int.parse(timeParts[1]);
final today = DateTime(now.year, now.month, now.day, hour, minute);
final isToday = today.isAfter(now);
scheduledTime = '${isToday ? 'Today' : 'Tomorrow'} at $reminderTime';
} else {
scheduledTime = 'Unknown time';
}
}
} catch (e) {
scheduledTime = 'ID: ${notification.id}';
}
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
leading: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.primary,
child: Text(
'${index + 1}',
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
fontWeight: FontWeight.bold,
),
),
),
title: Text(
notification.title ?? 'No title',
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('ID: ${notification.id}'),
Text(notification.body ?? 'No body'),
if (scheduledTime.isNotEmpty) ...[
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'$scheduledTime',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
],
],
),
isThreeLine: true,
),
);
},
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Close'),
),
],
),
);
}
},
icon: const Icon(Icons.list),
label: const Text('Show Pending'),
),
],
);
},
),
],
),
),
),
],
);
},
@@ -550,9 +334,9 @@ class _TimeRangeSelector extends StatelessWidget {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withOpacity(0.3)),
border: Border.all(color: color.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,

View File

@@ -0,0 +1,849 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/settings_provider.dart';
import '../providers/simple_sync_provider.dart';
import '../services/database_sync_service.dart';
class SimpleSyncSettingsScreen extends StatefulWidget {
const SimpleSyncSettingsScreen({super.key});
@override
State<SimpleSyncSettingsScreen> createState() => _SimpleSyncSettingsScreenState();
}
class _SimpleSyncSettingsScreenState extends State<SimpleSyncSettingsScreen> {
final _formKey = GlobalKey<FormState>();
final _serverUrlController = TextEditingController();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _remotePathController = TextEditingController();
String _previewUrl = '';
@override
void initState() {
super.initState();
_serverUrlController.addListener(_updatePreviewUrl);
_usernameController.addListener(_updatePreviewUrl);
_loadSavedConfiguration();
}
void _loadSavedConfiguration() {
WidgetsBinding.instance.addPostFrameCallback((_) {
final syncProvider = context.read<SimpleSyncProvider>();
if (syncProvider.serverUrl != null) {
_serverUrlController.text = _extractHostnameFromUrl(syncProvider.serverUrl!);
}
if (syncProvider.username != null) {
_usernameController.text = syncProvider.username!;
}
if (syncProvider.password != null) {
_passwordController.text = syncProvider.password!;
}
if (syncProvider.remotePath != null) {
_remotePathController.text = syncProvider.remotePath!;
}
_updatePreviewUrl();
});
}
String _extractHostnameFromUrl(String fullUrl) {
try {
final uri = Uri.parse(fullUrl);
return uri.host;
} catch (e) {
return fullUrl; // Return as-is if parsing fails
}
}
void _updatePreviewUrl() {
setState(() {
if (_serverUrlController.text.isNotEmpty && _usernameController.text.isNotEmpty) {
_previewUrl = _constructWebDAVUrl(_serverUrlController.text, _usernameController.text);
} else {
_previewUrl = '';
}
});
}
@override
void dispose() {
_serverUrlController.removeListener(_updatePreviewUrl);
_usernameController.removeListener(_updatePreviewUrl);
_serverUrlController.dispose();
_usernameController.dispose();
_passwordController.dispose();
_remotePathController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final syncProvider = context.watch<SimpleSyncProvider>();
return Scaffold(
appBar: AppBar(
title: const Text('Database Sync Settings'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: [
IconButton(
tooltip: 'Save Configuration',
onPressed: syncProvider.isSyncing ? null : _configureSync,
icon: const Icon(Icons.save),
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildStatusCard(syncProvider),
const SizedBox(height: 20),
_buildConfigurationSection(syncProvider),
const SizedBox(height: 20),
_buildActionButtons(),
],
),
),
),
);
}
Widget _buildStatusCard(SimpleSyncProvider syncProvider) {
IconData icon;
Color color;
String statusText = syncProvider.getStatusText();
switch (syncProvider.status) {
case SyncStatus.idle:
icon = syncProvider.isAutoSync ? Icons.sync_alt : Icons.sync;
color = Colors.blue;
break;
case SyncStatus.downloading:
case SyncStatus.merging:
case SyncStatus.uploading:
icon = syncProvider.isAutoSync ? Icons.sync_alt : Icons.sync;
color = syncProvider.isAutoSync ? Colors.deepOrange : Colors.orange;
break;
case SyncStatus.completed:
icon = syncProvider.isAutoSync ? Icons.check_circle_outline : Icons.check_circle;
color = Colors.green;
break;
case SyncStatus.error:
icon = Icons.error;
color = Colors.red;
break;
}
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Row(
children: [
Icon(icon, color: color, size: 24),
const SizedBox(width: 12),
Expanded(
child: Text(
statusText,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: color,
),
),
),
// Sync action inside the status card
if (syncProvider.isSyncing) ...[
const SizedBox(width: 12),
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2.2,
valueColor: AlwaysStoppedAnimation<Color>(color),
),
),
] else ...[
IconButton(
tooltip: 'Sync Database',
onPressed: (!syncProvider.isConfigured || syncProvider.isSyncing) ? null : _syncDatabase,
icon: const Icon(Icons.sync),
color: Theme.of(context).colorScheme.primary,
),
],
],
),
_buildAutoSyncStatusIndicator(syncProvider),
if (syncProvider.lastSyncTime != null) ...[
const SizedBox(height: 8),
Text(
'Last sync: ${_formatDateTime(syncProvider.lastSyncTime!)}',
style: Theme.of(context).textTheme.bodySmall,
),
],
// Show auto-sync specific errors
if (syncProvider.isAutoSyncDisabledDueToErrors) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.warning, color: Colors.red, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
'Auto-sync disabled due to repeated failures',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Colors.red,
fontWeight: FontWeight.w600,
),
),
),
],
),
if (syncProvider.autoSyncLastError != null) ...[
const SizedBox(height: 8),
Text(
syncProvider.autoSyncLastError!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.red[700],
),
),
],
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: () => syncProvider.resetAutoSyncErrors(),
icon: const Icon(Icons.refresh, size: 16),
label: const Text('Reset & Re-enable'),
style: TextButton.styleFrom(
foregroundColor: Colors.red,
),
),
],
),
],
),
),
] else if (syncProvider.lastError != null) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: Row(
children: [
Expanded(
child: Text(
_getErrorMessage(syncProvider),
style: const TextStyle(color: Colors.red),
),
),
IconButton(
icon: const Icon(Icons.close, color: Colors.red),
onPressed: () => syncProvider.clearError(),
),
],
),
),
],
],
),
),
);
}
Widget _buildConfigurationSection(SimpleSyncProvider syncProvider) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Sync Configuration',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
_buildAutoSyncSection(),
],
),
),
),
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'WebDAV URL Preview:',
style: Theme.of(context).textTheme.labelMedium,
),
const SizedBox(height: 4),
SelectableText(
_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(
controller: _remotePathController,
decoration: const InputDecoration(
labelText: 'Remote Path (optional)',
hintText: 'Supplements/',
border: OutlineInputBorder(),
),
),
],
),
),
),
],
);
}
Widget _buildActionButtons() {
// Buttons have been moved into the AppBar / cards. Keep a small spacer here for layout.
return const SizedBox.shrink();
}
Widget _buildAutoSyncSection() {
return Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
return Consumer<SimpleSyncProvider>(
builder: (context, syncProvider, child) {
return Container(
padding: const EdgeInsets.all(16.0),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SwitchListTile(
title: Row(
children: [
const Text(
'Auto-sync',
style: TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(width: 8),
_buildAutoSyncStatusBadge(settingsProvider, syncProvider),
],
),
subtitle: Text(
settingsProvider.autoSyncEnabled
? 'Automatically sync when you make changes'
: 'Sync manually using the sync button',
style: Theme.of(context).textTheme.bodySmall,
),
value: settingsProvider.autoSyncEnabled,
onChanged: (bool value) async {
await settingsProvider.setAutoSyncEnabled(value);
},
contentPadding: EdgeInsets.zero,
),
if (settingsProvider.autoSyncEnabled) ...[
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Text(
'Changes are debounced for ${settingsProvider.autoSyncDebounceSeconds} seconds to prevent excessive syncing.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Debounce timeout',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
SegmentedButton<int>(
segments: const [
ButtonSegment(value: 1, label: Text('1s')),
ButtonSegment(value: 5, label: Text('5s')),
ButtonSegment(value: 15, label: Text('15s')),
ButtonSegment(value: 30, label: Text('30s')),
],
selected: {settingsProvider.autoSyncDebounceSeconds},
onSelectionChanged: (values) {
settingsProvider.setAutoSyncDebounceSeconds(values.first);
},
),
],
),
),
],
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.info_outline,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Auto-sync triggers when you add, update, or delete supplements and intakes. Configure your WebDAV settings below to enable syncing.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
],
),
),
],
),
);
},
);
},
);
}
Future<void> _testConnection() async {
if (!_formKey.currentState!.validate()) return;
final syncProvider = context.read<SimpleSyncProvider>();
try {
// Construct the full WebDAV URL from the simple hostname
final fullWebDAVUrl = _constructWebDAVUrl(
_serverUrlController.text.trim(),
_usernameController.text.trim(),
);
// Configure temporarily for testing
await syncProvider.configure(
serverUrl: fullWebDAVUrl,
username: _usernameController.text.trim(),
password: _passwordController.text.trim(),
remotePath: _remotePathController.text.trim().isEmpty
? 'Supplements'
: _remotePathController.text.trim(),
);
final success = await syncProvider.testConnection();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(success
? 'Connection successful!'
: 'Connection failed. Check your settings.'),
backgroundColor: success ? Colors.green : Colors.red,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Connection test failed: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
Future<void> _configureSync() async {
if (!_formKey.currentState!.validate()) return;
final syncProvider = context.read<SimpleSyncProvider>();
try {
// Construct the full WebDAV URL from the simple hostname
final fullWebDAVUrl = _constructWebDAVUrl(
_serverUrlController.text.trim(),
_usernameController.text.trim(),
);
await syncProvider.configure(
serverUrl: fullWebDAVUrl,
username: _usernameController.text.trim(),
password: _passwordController.text.trim(),
remotePath: _remotePathController.text.trim().isEmpty
? 'Supplements'
: _remotePathController.text.trim(),
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Configuration saved successfully!'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to save configuration: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
Future<void> _syncDatabase() async {
final syncProvider = context.read<SimpleSyncProvider>();
try {
await syncProvider.syncDatabase(isAutoSync: false);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Manual sync completed!'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Manual sync failed: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
String _constructWebDAVUrl(String serverUrl, String username) {
// Remove any protocol prefix if present
String cleanUrl = serverUrl.trim();
if (cleanUrl.startsWith('http://')) {
cleanUrl = cleanUrl.substring(7);
} else if (cleanUrl.startsWith('https://')) {
cleanUrl = cleanUrl.substring(8);
}
// Remove trailing slash if present
if (cleanUrl.endsWith('/')) {
cleanUrl = cleanUrl.substring(0, cleanUrl.length - 1);
}
// For Nextcloud instances, construct the standard WebDAV path
// Default to HTTPS for security
return 'https://$cleanUrl/remote.php/dav/files/$username/';
}
Widget _buildAutoSyncStatusIndicator(SimpleSyncProvider syncProvider) {
return Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) {
// Only show auto-sync status if auto-sync is enabled
if (!settingsProvider.autoSyncEnabled) {
return const SizedBox.shrink();
}
// Check if auto-sync service has pending sync
final autoSyncService = syncProvider.autoSyncService;
if (autoSyncService == null) {
return const SizedBox.shrink();
}
// Show pending auto-sync indicator
if (autoSyncService.hasPendingSync && !syncProvider.isSyncing) {
return Container(
margin: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.blue.withValues(alpha: 0.3)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 1.5,
valueColor: const AlwaysStoppedAnimation<Color>(Colors.blue),
),
),
const SizedBox(width: 8),
Text(
'Auto-sync pending...',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.blue,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
// Show auto-sync active indicator (when sync is running and it's auto-triggered)
if (syncProvider.isSyncing && syncProvider.isAutoSync) {
return Container(
margin: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.deepOrange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.deepOrange.withValues(alpha: 0.3)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.sync_alt,
size: 12,
color: Colors.deepOrange,
),
const SizedBox(width: 8),
Text(
'Auto-sync active',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.deepOrange,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
return const SizedBox.shrink();
},
);
}
Widget _buildAutoSyncStatusBadge(SettingsProvider settingsProvider, SimpleSyncProvider syncProvider) {
if (!settingsProvider.autoSyncEnabled) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'OFF',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.grey[600],
fontWeight: FontWeight.w600,
),
),
);
}
// Check if auto-sync is disabled due to errors
if (syncProvider.isAutoSyncDisabledDueToErrors) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'ERROR',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.red[700],
fontWeight: FontWeight.w600,
),
),
);
}
// Check if sync is configured
if (!syncProvider.isConfigured) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'NOT CONFIGURED',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.orange[700],
fontWeight: FontWeight.w600,
),
),
);
}
// Check if there are recent failures but not disabled yet
if (syncProvider.autoSyncConsecutiveFailures > 0) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'RETRYING',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.orange[700],
fontWeight: FontWeight.w600,
),
),
);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'ACTIVE',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.green[700],
fontWeight: FontWeight.w600,
),
),
);
}
String _getErrorMessage(SimpleSyncProvider syncProvider) {
final error = syncProvider.lastError ?? 'Unknown error';
// Add context for auto-sync errors
if (syncProvider.isAutoSync) {
return 'Auto-sync error: $error';
}
return error;
}
String _formatDateTime(DateTime dateTime) {
return '${dateTime.day}/${dateTime.month}/${dateTime.year} ${dateTime.hour}:${dateTime.minute.toString().padLeft(2, '0')}';
}
}

View File

@@ -1,9 +1,13 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/supplement_provider.dart';
import '../providers/settings_provider.dart';
import '../models/supplement.dart';
import '../providers/settings_provider.dart';
import '../providers/simple_sync_provider.dart';
import '../providers/supplement_provider.dart';
import '../services/database_sync_service.dart';
import '../widgets/supplement_card.dart';
import '../widgets/dialogs/take_supplement_dialog.dart';
import 'add_supplement_screen.dart';
import 'archived_supplements_screen.dart';
@@ -14,9 +18,34 @@ class SupplementsListScreen extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('My Supplements'),
title: const Text('Edit Supplements'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: [
Consumer<SimpleSyncProvider>(
builder: (context, syncProvider, child) {
if (!syncProvider.isConfigured) {
return const SizedBox.shrink();
}
return IconButton(
icon: syncProvider.isSyncing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: syncProvider.status == SyncStatus.completed &&
syncProvider.lastSyncTime != null &&
DateTime.now().difference(syncProvider.lastSyncTime!).inSeconds < 5
? const Icon(Icons.check, color: Colors.green)
: const Icon(Icons.sync),
onPressed: syncProvider.isSyncing ? null : () {
syncProvider.syncDatabase();
},
tooltip: syncProvider.isSyncing ? 'Syncing...' : 'Force Sync',
);
},
),
IconButton(
icon: const Icon(Icons.archive),
onPressed: () {
@@ -71,28 +100,30 @@ class SupplementsListScreen extends StatelessWidget {
await provider.loadSupplements();
await provider.refreshDailyStatus();
},
child: _buildGroupedSupplementsList(context, provider.supplements, settingsProvider),
child: _buildGroupedSupplementsList(context, provider.supplements, settingsProvider, provider),
);
},
),
);
}
Widget _buildGroupedSupplementsList(BuildContext context, List<Supplement> supplements, SettingsProvider settingsProvider) {
Widget _buildGroupedSupplementsList(BuildContext context, List<Supplement> supplements, SettingsProvider settingsProvider, SupplementProvider provider) {
final groupedSupplements = _groupSupplementsByTimeOfDay(supplements, settingsProvider);
return ListView(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.fromLTRB(16, 16, 16, 80),
children: [
if (groupedSupplements['morning']!.isNotEmpty) ...[
_buildSectionHeader('Morning (${settingsProvider.morningRange})', Icons.wb_sunny, Colors.orange, groupedSupplements['morning']!.length),
...groupedSupplements['morning']!.map((supplement) =>
SupplementCard(
supplement: supplement,
onTake: () => _showTakeDialog(context, supplement),
onTake: () => showTakeSupplementDialog(context, supplement),
onEdit: () => _editSupplement(context, supplement),
onDelete: () => _deleteSupplement(context, supplement),
onArchive: () => _archiveSupplement(context, supplement),
onDuplicate: () => context.read<SupplementProvider>().duplicateSupplement(supplement.id!),
showCompletionStatus: false,
),
),
const SizedBox(height: 16),
@@ -103,10 +134,12 @@ class SupplementsListScreen extends StatelessWidget {
...groupedSupplements['afternoon']!.map((supplement) =>
SupplementCard(
supplement: supplement,
onTake: () => _showTakeDialog(context, supplement),
onTake: () => showTakeSupplementDialog(context, supplement),
onEdit: () => _editSupplement(context, supplement),
onDelete: () => _deleteSupplement(context, supplement),
onArchive: () => _archiveSupplement(context, supplement),
onDuplicate: () => context.read<SupplementProvider>().duplicateSupplement(supplement.id!),
showCompletionStatus: false,
),
),
const SizedBox(height: 16),
@@ -117,10 +150,12 @@ class SupplementsListScreen extends StatelessWidget {
...groupedSupplements['evening']!.map((supplement) =>
SupplementCard(
supplement: supplement,
onTake: () => _showTakeDialog(context, supplement),
onTake: () => showTakeSupplementDialog(context, supplement),
onEdit: () => _editSupplement(context, supplement),
onDelete: () => _deleteSupplement(context, supplement),
onArchive: () => _archiveSupplement(context, supplement),
onDuplicate: () => context.read<SupplementProvider>().duplicateSupplement(supplement.id!),
showCompletionStatus: false,
),
),
const SizedBox(height: 16),
@@ -131,10 +166,12 @@ class SupplementsListScreen extends StatelessWidget {
...groupedSupplements['night']!.map((supplement) =>
SupplementCard(
supplement: supplement,
onTake: () => _showTakeDialog(context, supplement),
onTake: () => showTakeSupplementDialog(context, supplement),
onEdit: () => _editSupplement(context, supplement),
onDelete: () => _deleteSupplement(context, supplement),
onArchive: () => _archiveSupplement(context, supplement),
onDuplicate: () => context.read<SupplementProvider>().duplicateSupplement(supplement.id!),
showCompletionStatus: false,
),
),
const SizedBox(height: 16),
@@ -145,13 +182,16 @@ class SupplementsListScreen extends StatelessWidget {
...groupedSupplements['anytime']!.map((supplement) =>
SupplementCard(
supplement: supplement,
onTake: () => _showTakeDialog(context, supplement),
onTake: () => showTakeSupplementDialog(context, supplement),
onEdit: () => _editSupplement(context, supplement),
onDelete: () => _deleteSupplement(context, supplement),
onArchive: () => _archiveSupplement(context, supplement),
onDuplicate: () => context.read<SupplementProvider>().duplicateSupplement(supplement.id!),
showCompletionStatus: false,
),
),
],
],
);
}
@@ -161,10 +201,10 @@ class SupplementsListScreen extends StatelessWidget {
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: color.withOpacity(0.3),
color: color.withValues(alpha: 0.3),
width: 1,
),
),
@@ -173,7 +213,7 @@ class SupplementsListScreen extends StatelessWidget {
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.2),
color: color.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child: Icon(
@@ -201,7 +241,7 @@ class SupplementsListScreen extends StatelessWidget {
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: color.withOpacity(0.8),
color: color.withValues(alpha: 0.8),
),
),
],
@@ -211,7 +251,7 @@ class SupplementsListScreen extends StatelessWidget {
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.15),
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Text(
@@ -245,267 +285,6 @@ class SupplementsListScreen extends StatelessWidget {
return grouped;
}
void _showTakeDialog(BuildContext context, Supplement supplement) {
final unitsController = TextEditingController(text: supplement.numberOfUnits.toString());
final notesController = TextEditingController();
DateTime selectedDateTime = DateTime.now();
bool useCustomTime = false;
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),
// 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: useCustomTime ? selectedDateTime : null,
);
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${supplement.name} recorded!'),
backgroundColor: Colors.green,
),
);
},
child: const Text('Record'),
),
],
);
},
),
);
}
void _editSupplement(BuildContext context, Supplement supplement) {
Navigator.of(context).push(
MaterialPageRoute(

View File

View File

@@ -0,0 +1,456 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:popover/popover.dart';
import '../models/supplement.dart';
import '../providers/settings_provider.dart';
import '../providers/simple_sync_provider.dart';
import '../providers/supplement_provider.dart';
import '../services/database_sync_service.dart';
import '../widgets/dialogs/take_supplement_dialog.dart';
class TodayScheduleScreen extends StatelessWidget {
const TodayScheduleScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("My Supplements"),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: [
Consumer<SimpleSyncProvider>(
builder: (context, syncProvider, child) {
if (!syncProvider.isConfigured) {
return const SizedBox.shrink();
}
return IconButton(
icon: syncProvider.isSyncing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: syncProvider.status == SyncStatus.completed &&
syncProvider.lastSyncTime != null &&
DateTime.now().difference(syncProvider.lastSyncTime!).inSeconds < 5
? const Icon(Icons.check, color: Colors.green)
: const Icon(Icons.sync),
onPressed: syncProvider.isSyncing ? null : () {
syncProvider.syncDatabase();
},
tooltip: syncProvider.isSyncing ? 'Syncing...' : 'Force Sync',
);
},
),
],
),
body: Consumer2<SupplementProvider, SettingsProvider>(
builder: (context, provider, settingsProvider, child) {
if (provider.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (provider.todayIntakes.isEmpty && provider.supplements.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.schedule,
size: 64,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'No schedule for today',
style: TextStyle(
fontSize: 18,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
'Add supplements with reminder times to see your schedule',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
);
}
return RefreshIndicator(
onRefresh: () async {
await provider.loadSupplements();
await provider.refreshDailyStatus();
},
child: _buildTimelineView(context, provider, settingsProvider),
);
},
),
);
}
Widget _buildTimelineView(BuildContext context, SupplementProvider provider, SettingsProvider settingsProvider) {
final scheduledItems = _getScheduledItems(provider.supplements, provider.todayIntakes);
final groupedItems = _groupScheduledItemsByTimeOfDay(scheduledItems);
return ListView(
padding: const EdgeInsets.all(16),
children: [
if (groupedItems['morning']!.isNotEmpty) ...[
_buildTimeSectionHeader('Morning', Icons.wb_sunny, Colors.orange),
...groupedItems['morning']!.map((item) => _buildScheduledItem(context, item)),
const SizedBox(height: 12),
],
if (groupedItems['afternoon']!.isNotEmpty) ...[
_buildTimeSectionHeader('Afternoon', Icons.light_mode, Colors.blue),
...groupedItems['afternoon']!.map((item) => _buildScheduledItem(context, item)),
const SizedBox(height: 12),
],
if (groupedItems['evening']!.isNotEmpty) ...[
_buildTimeSectionHeader('Evening', Icons.nightlight_round, Colors.indigo),
...groupedItems['evening']!.map((item) => _buildScheduledItem(context, item)),
const SizedBox(height: 12),
],
if (groupedItems['night']!.isNotEmpty) ...[
_buildTimeSectionHeader('Night', Icons.bedtime, Colors.purple),
...groupedItems['night']!.map((item) => _buildScheduledItem(context, item)),
],
],
);
}
Widget _buildTimeSectionHeader(String title, IconData icon, Color color) {
return Container(
margin: const EdgeInsets.only(bottom: 8, top: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 8),
Text(
title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: color,
),
),
],
),
);
}
Widget _buildScheduledItem(BuildContext context, Map<String, dynamic> item) {
final time = item['time'] as String;
final supplement = item['supplement'] as Supplement;
final status = item['status'] as String;
final actualTime = item['actualTime'] as String?;
final isCompleted = status == 'on_time' || status == 'off_time';
final isOnTime = status == 'on_time';
return Builder(
builder: (context) => InkWell(
onTap: () {
showPopover(
context: context,
bodyBuilder: (context) => Container(
constraints: const BoxConstraints(maxWidth: 150),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3),
width: 1,
),
boxShadow: [
BoxShadow(
color: Theme.of(context).colorScheme.shadow.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (isCompleted)
_buildPopoverItem(
context: context,
icon: Icons.undo,
label: 'Undo Last Taken',
onTap: () async {
Navigator.of(context).pop();
final provider = context.read<SupplementProvider>();
final mostRecentIntake = provider.getMostRecentIntake(supplement.id!);
if (mostRecentIntake != null) {
await provider.deleteIntake(mostRecentIntake['id']);
// Refresh the schedule after undoing
if (context.mounted) {
provider.refreshDailyStatus();
}
}
},
color: Colors.orange,
),
_buildPopoverItem(
context: context,
icon: isCompleted ? Icons.add_circle_outline : Icons.medication,
label: isCompleted ? 'Take Again' : 'Take',
onTap: () async {
Navigator.of(context).pop();
await showTakeSupplementDialog(context, supplement);
// Refresh the schedule after taking
if (context.mounted) {
context.read<SupplementProvider>().refreshDailyStatus();
}
},
),
],
),
),
direction: PopoverDirection.top,
width: 180,
height: null,
arrowHeight: 0,
arrowWidth: 0,
backgroundColor: Colors.transparent,
shadow: const [],
);
},
borderRadius: BorderRadius.circular(8),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isCompleted
? Theme.of(context).colorScheme.surface
: Theme.of(context).colorScheme.primary.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isCompleted
? Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)
: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3),
),
),
child: Row(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
time,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: isCompleted
? Theme.of(context).colorScheme.onSurfaceVariant
: Theme.of(context).colorScheme.primary,
),
),
if (actualTime != null && actualTime != time)
Text(
'Taken: $actualTime',
style: TextStyle(
fontSize: 10,
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
),
),
],
),
const SizedBox(width: 12),
Expanded(
child: Text(
supplement.name,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: isCompleted
? Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7)
: Theme.of(context).colorScheme.onSurface,
decoration: isCompleted ? TextDecoration.lineThrough : null,
),
),
),
if (isCompleted)
Row(
children: [
if (!isOnTime)
Icon(
Icons.schedule,
size: 14,
color: Colors.orange,
),
const SizedBox(width: 4),
Icon(
Icons.check_circle,
size: 16,
color: isOnTime ? Colors.green : Colors.orange,
),
],
)
else
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'Pending',
style: TextStyle(
fontSize: 10,
color: Theme.of(context).colorScheme.onPrimary,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
);
}
Widget _buildPopoverItem({
required BuildContext context,
required IconData icon,
required String label,
required VoidCallback onTap,
Color? color,
}) {
return InkWell(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(
icon,
size: 18,
color: color ?? Theme.of(context).colorScheme.onSurface,
),
const SizedBox(width: 12),
Text(
label,
style: TextStyle(
fontSize: 14,
color: color ?? Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}
List<Map<String, dynamic>> _getScheduledItems(List<Supplement> supplements, List<Map<String, dynamic>> todayIntakes) {
final List<Map<String, dynamic>> scheduledItems = [];
for (final supplement in supplements) {
for (final reminderTime in supplement.reminderTimes) {
// Parse reminder time (format: "HH:MM")
final parts = reminderTime.split(':');
final hour = int.parse(parts[0]);
final minute = int.parse(parts[1]);
// Check if this reminder has been taken today
String status = 'pending';
String? actualTime;
for (final intake in todayIntakes) {
if (intake['supplement_id'] == supplement.id) {
final takenAt = DateTime.parse(intake['takenAt']);
final reminderDateTime = DateTime(
takenAt.year,
takenAt.month,
takenAt.day,
hour,
minute,
);
// Check if taken within 1 hour of reminder time
final timeDiff = (takenAt.difference(reminderDateTime)).inMinutes.abs();
if (timeDiff <= 60) {
status = 'on_time';
actualTime = '${takenAt.hour.toString().padLeft(2, '0')}:${takenAt.minute.toString().padLeft(2, '0')}';
} else {
status = 'off_time';
actualTime = '${takenAt.hour.toString().padLeft(2, '0')}:${takenAt.minute.toString().padLeft(2, '0')}';
}
break;
}
}
scheduledItems.add({
'time': reminderTime,
'supplement': supplement,
'status': status,
'actualTime': actualTime,
});
}
}
// Sort by time
scheduledItems.sort((a, b) {
final timeA = a['time'] as String;
final timeB = b['time'] as String;
return timeA.compareTo(timeB);
});
return scheduledItems;
}
Map<String, List<Map<String, dynamic>>> _groupScheduledItemsByTimeOfDay(List<Map<String, dynamic>> scheduledItems) {
final Map<String, List<Map<String, dynamic>>> grouped = {
'morning': <Map<String, dynamic>>[],
'afternoon': <Map<String, dynamic>>[],
'evening': <Map<String, dynamic>>[],
'night': <Map<String, dynamic>>[],
};
for (final item in scheduledItems) {
final time = item['time'] as String;
final parts = time.split(':');
final hour = int.parse(parts[0]);
String category;
if (hour >= 6 && hour < 12) {
category = 'morning';
} else if (hour >= 12 && hour < 18) {
category = 'afternoon';
} else if (hour >= 18 && hour < 22) {
category = 'evening';
} else {
category = 'night';
}
grouped[category]!.add(item);
}
// Sort items by time within each category
for (final category in grouped.keys) {
grouped[category]!.sort((a, b) {
final timeA = a['time'] as String;
final timeB = b['time'] as String;
return timeA.compareTo(timeB);
});
}
return grouped;
}
}

View File

@@ -0,0 +1,471 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:supplements/logging.dart';
import '../providers/settings_provider.dart';
import '../providers/simple_sync_provider.dart';
/// Error types for auto-sync operations
enum AutoSyncErrorType {
network,
configuration,
authentication,
server,
unknown,
}
/// Represents an auto-sync error with context
class AutoSyncError {
final AutoSyncErrorType type;
final String message;
final DateTime timestamp;
final dynamic originalError;
AutoSyncError({
required this.type,
required this.message,
required this.timestamp,
this.originalError,
});
@override
String toString() => 'AutoSyncError($type): $message';
}
/// Service that handles automatic synchronization with debouncing logic
/// to prevent excessive sync requests when multiple data changes occur rapidly.
class AutoSyncService {
Timer? _debounceTimer;
bool _syncInProgress = false;
bool _hasPendingSync = false;
// Error handling and retry logic
final List<AutoSyncError> _recentErrors = [];
int _consecutiveFailures = 0;
DateTime? _lastFailureTime;
Timer? _retryTimer;
bool _autoDisabledDueToErrors = false;
// Exponential backoff configuration
static const int _maxRetryAttempts = 5;
static const int _baseRetryDelaySeconds = 30;
static const int _maxRetryDelaySeconds = 300; // 5 minutes
static const int _errorHistoryMaxSize = 10;
static const int _autoDisableThreshold = 3; // Consecutive failures before auto-disable
final SimpleSyncProvider _syncProvider;
final SettingsProvider _settingsProvider;
AutoSyncService({
required SimpleSyncProvider syncProvider,
required SettingsProvider settingsProvider,
}) : _syncProvider = syncProvider,
_settingsProvider = settingsProvider;
/// Triggers an auto-sync if enabled in settings.
/// Uses debouncing to prevent excessive sync requests.
void triggerAutoSync() {
// Check if auto-sync is enabled
if (!_settingsProvider.autoSyncEnabled) {
if (kDebugMode) {
printLog('AutoSyncService: Auto-sync is disabled, skipping trigger');
}
return;
}
// Check if auto-sync was disabled due to persistent errors
if (_autoDisabledDueToErrors) {
if (kDebugMode) {
printLog('AutoSyncService: Auto-sync disabled due to persistent errors, skipping trigger');
}
return;
}
// Check if sync is configured
if (!_syncProvider.isConfigured) {
_recordError(AutoSyncError(
type: AutoSyncErrorType.configuration,
message: 'Sync not configured. Please configure cloud sync settings.',
timestamp: DateTime.now(),
));
if (kDebugMode) {
printLog('AutoSyncService: Sync not configured, skipping auto-sync');
}
return;
}
// If sync is already in progress, mark that we have a pending sync
if (_syncInProgress || _syncProvider.isSyncing) {
_hasPendingSync = true;
if (kDebugMode) {
printLog('AutoSyncService: Sync in progress, marking pending sync');
}
return;
}
// Cancel existing timer if one is running
_cancelPendingSync();
// Check if we should apply exponential backoff
final backoffDelay = _calculateBackoffDelay();
if (backoffDelay > 0) {
if (kDebugMode) {
printLog('AutoSyncService: Applying backoff delay of ${backoffDelay}s due to recent failures');
}
_debounceTimer = Timer(Duration(seconds: backoffDelay), () {
_executePendingSync();
});
return;
}
// Start new debounce timer
final debounceSeconds = _settingsProvider.autoSyncDebounceSeconds;
_debounceTimer = Timer(Duration(seconds: debounceSeconds), () {
_executePendingSync();
});
if (kDebugMode) {
printLog('AutoSyncService: Auto-sync scheduled in ${debounceSeconds}s');
}
}
/// Executes the pending sync operation
Future<void> _executePendingSync() async {
// Double-check conditions before executing
if (!_settingsProvider.autoSyncEnabled) {
if (kDebugMode) {
printLog('AutoSyncService: Auto-sync disabled during execution, aborting');
}
return;
}
if (_autoDisabledDueToErrors) {
if (kDebugMode) {
printLog('AutoSyncService: Auto-sync disabled due to errors during execution, aborting');
}
return;
}
if (!_syncProvider.isConfigured) {
_recordError(AutoSyncError(
type: AutoSyncErrorType.configuration,
message: 'Sync not configured during execution',
timestamp: DateTime.now(),
));
if (kDebugMode) {
printLog('AutoSyncService: Sync not configured during execution, aborting');
}
return;
}
if (_syncInProgress || _syncProvider.isSyncing) {
if (kDebugMode) {
printLog('AutoSyncService: Sync already in progress during execution, aborting');
}
return;
}
_syncInProgress = true;
_hasPendingSync = false;
try {
if (kDebugMode) {
printLog('AutoSyncService: Executing auto-sync (attempt ${_consecutiveFailures + 1})');
}
// Check network connectivity before attempting sync
if (!await _isNetworkAvailable()) {
throw AutoSyncError(
type: AutoSyncErrorType.network,
message: 'Network is not available',
timestamp: DateTime.now(),
);
}
await _syncProvider.syncDatabase(isAutoSync: true);
// Reset failure count on successful sync
_consecutiveFailures = 0;
_lastFailureTime = null;
_autoDisabledDueToErrors = false;
if (kDebugMode) {
printLog('AutoSyncService: Auto-sync completed successfully');
}
} catch (e) {
if (kDebugMode) {
printLog('AutoSyncService: Auto-sync failed: $e');
}
// Handle specific error types
_handleSyncError(e);
} finally {
_syncInProgress = false;
// If there was a pending sync request while we were syncing, trigger it
if (_hasPendingSync && !_autoDisabledDueToErrors) {
if (kDebugMode) {
printLog('AutoSyncService: Processing queued sync request');
}
_hasPendingSync = false;
// Use a small delay to avoid immediate re-triggering
Timer(const Duration(milliseconds: 500), () {
triggerAutoSync();
});
}
}
}
/// Handles sync errors with appropriate recovery strategies
void _handleSyncError(dynamic error) {
_consecutiveFailures++;
_lastFailureTime = DateTime.now();
final autoSyncError = _categorizeError(error);
_recordError(autoSyncError);
// Check if we should disable auto-sync due to persistent errors
if (_consecutiveFailures >= _autoDisableThreshold) {
_autoDisabledDueToErrors = true;
if (kDebugMode) {
printLog('AutoSyncService: Auto-sync disabled due to $_consecutiveFailures consecutive failures');
}
// For configuration errors, disable immediately
if (autoSyncError.type == AutoSyncErrorType.configuration ||
autoSyncError.type == AutoSyncErrorType.authentication) {
if (kDebugMode) {
printLog('AutoSyncService: Auto-sync disabled due to configuration/authentication error');
}
}
}
// Schedule retry for recoverable errors (unless auto-disabled)
if (!_autoDisabledDueToErrors && _shouldRetry(autoSyncError.type)) {
_scheduleRetry();
}
}
/// Categorizes an error into a specific AutoSyncError type
AutoSyncError _categorizeError(dynamic error) {
final errorString = error.toString().toLowerCase();
// Network-related errors
if (error is SocketException ||
errorString.contains('network') ||
errorString.contains('connection') ||
errorString.contains('timeout') ||
errorString.contains('unreachable') ||
errorString.contains('host lookup failed') ||
errorString.contains('no route to host')) {
return AutoSyncError(
type: AutoSyncErrorType.network,
message: 'Network connection failed. Check your internet connection.',
timestamp: DateTime.now(),
originalError: error,
);
}
// Configuration-related errors
if (errorString.contains('not configured') ||
errorString.contains('invalid url') ||
errorString.contains('malformed url')) {
return AutoSyncError(
type: AutoSyncErrorType.configuration,
message: 'Sync configuration is invalid. Please check your sync settings.',
timestamp: DateTime.now(),
originalError: error,
);
}
// Authentication errors
if (errorString.contains('authentication') ||
errorString.contains('unauthorized') ||
errorString.contains('401') ||
errorString.contains('403') ||
errorString.contains('invalid credentials')) {
return AutoSyncError(
type: AutoSyncErrorType.authentication,
message: 'Authentication failed. Please check your username and password.',
timestamp: DateTime.now(),
originalError: error,
);
}
// Server errors
if (errorString.contains('500') ||
errorString.contains('502') ||
errorString.contains('503') ||
errorString.contains('504') ||
errorString.contains('server error')) {
return AutoSyncError(
type: AutoSyncErrorType.server,
message: 'Server error occurred. The sync server may be temporarily unavailable.',
timestamp: DateTime.now(),
originalError: error,
);
}
// Unknown errors
return AutoSyncError(
type: AutoSyncErrorType.unknown,
message: 'An unexpected error occurred during sync: ${error.toString()}',
timestamp: DateTime.now(),
originalError: error,
);
}
/// Records an error in the recent errors list
void _recordError(AutoSyncError error) {
_recentErrors.add(error);
// Keep only recent errors
if (_recentErrors.length > _errorHistoryMaxSize) {
_recentErrors.removeAt(0);
}
if (kDebugMode) {
printLog('AutoSyncService: Recorded error: $error');
}
}
/// Determines if we should retry for a given error type
bool _shouldRetry(AutoSyncErrorType errorType) {
switch (errorType) {
case AutoSyncErrorType.network:
case AutoSyncErrorType.server:
return _consecutiveFailures < _maxRetryAttempts;
case AutoSyncErrorType.configuration:
case AutoSyncErrorType.authentication:
return false; // Don't retry config/auth errors
case AutoSyncErrorType.unknown:
return _consecutiveFailures < _maxRetryAttempts;
}
}
/// Calculates the backoff delay based on consecutive failures
int _calculateBackoffDelay() {
if (_consecutiveFailures == 0 || _lastFailureTime == null) {
return 0;
}
// Calculate exponential backoff: base * (2^failures)
final backoffSeconds = min(
_baseRetryDelaySeconds * pow(2, _consecutiveFailures - 1).toInt(),
_maxRetryDelaySeconds,
);
// Check if enough time has passed since last failure
final timeSinceLastFailure = DateTime.now().difference(_lastFailureTime!).inSeconds;
if (timeSinceLastFailure >= backoffSeconds) {
return 0; // No additional delay needed
}
return backoffSeconds - timeSinceLastFailure;
}
/// Schedules a retry attempt with exponential backoff
void _scheduleRetry() {
final retryDelay = _calculateBackoffDelay();
if (retryDelay <= 0) return;
_retryTimer?.cancel();
_retryTimer = Timer(Duration(seconds: retryDelay), () {
if (kDebugMode) {
printLog('AutoSyncService: Retrying auto-sync after backoff delay');
}
triggerAutoSync();
});
if (kDebugMode) {
printLog('AutoSyncService: Scheduled retry in ${retryDelay}s');
}
}
/// Checks if network is available
Future<bool> _isNetworkAvailable() async {
try {
final result = await InternetAddress.lookup('google.com');
return result.isNotEmpty && result[0].rawAddress.isNotEmpty;
} catch (e) {
if (kDebugMode) {
printLog('AutoSyncService: Network check failed: $e');
}
return false;
}
}
/// Cancels any pending sync operation
void cancelPendingSync() {
_cancelPendingSync();
_retryTimer?.cancel();
_retryTimer = null;
_hasPendingSync = false;
if (kDebugMode) {
printLog('AutoSyncService: Cancelled pending sync and retry timer');
}
}
/// Internal method to cancel the debounce timer
void _cancelPendingSync() {
_debounceTimer?.cancel();
_debounceTimer = null;
}
/// Resets error state and re-enables auto-sync if it was disabled
void resetErrorState() {
_consecutiveFailures = 0;
_lastFailureTime = null;
_autoDisabledDueToErrors = false;
_recentErrors.clear();
_retryTimer?.cancel();
_retryTimer = null;
if (kDebugMode) {
printLog('AutoSyncService: Error state reset, auto-sync re-enabled');
}
}
/// Disposes of the service and cleans up resources
void dispose() {
_cancelPendingSync();
_retryTimer?.cancel();
_retryTimer = null;
_hasPendingSync = false;
_syncInProgress = false;
_recentErrors.clear();
if (kDebugMode) {
printLog('AutoSyncService: Disposed');
}
}
/// Returns true if there is a pending sync operation
bool get hasPendingSync => _hasPendingSync || _debounceTimer != null;
/// Returns true if a sync is currently in progress
bool get isSyncInProgress => _syncInProgress;
/// Returns true if auto-sync was disabled due to persistent errors
bool get isAutoDisabledDueToErrors => _autoDisabledDueToErrors;
/// Returns the number of consecutive failures
int get consecutiveFailures => _consecutiveFailures;
/// Returns a copy of recent errors
List<AutoSyncError> get recentErrors => List.unmodifiable(_recentErrors);
/// Returns the last error message suitable for display to users
String? get lastErrorMessage {
if (_recentErrors.isEmpty) return null;
return _recentErrors.last.message;
}
/// Returns true if a retry is currently scheduled
bool get hasScheduledRetry => _retryTimer != null;
}

View File

@@ -1,18 +1,22 @@
import 'package:sqflite/sqflite.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:path/path.dart';
import 'dart:io';
import 'dart:convert';
import 'dart:io';
import 'package:path/path.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import '../models/supplement.dart';
import '../models/supplement_intake.dart';
import 'database_sync_service.dart';
class DatabaseHelper {
static const _databaseName = 'supplements.db';
static const _databaseVersion = 5; // Increment version for notification tracking
static const _databaseVersion = 6; // Increment version for sync support
static const supplementsTable = 'supplements';
static const intakesTable = 'supplement_intakes';
static const notificationTrackingTable = 'notification_tracking';
static const syncMetadataTable = 'sync_metadata';
static const deviceInfoTable = 'device_info';
DatabaseHelper._privateConstructor();
static final DatabaseHelper instance = DatabaseHelper._privateConstructor();
@@ -60,7 +64,11 @@ class DatabaseHelper {
reminderTimes TEXT NOT NULL,
notes TEXT,
createdAt TEXT NOT NULL,
isActive INTEGER NOT NULL DEFAULT 1
isActive INTEGER NOT NULL DEFAULT 1,
syncId TEXT NOT NULL UNIQUE,
lastModified TEXT NOT NULL,
syncStatus TEXT NOT NULL DEFAULT 'pending',
isDeleted INTEGER NOT NULL DEFAULT 0
)
''');
@@ -72,6 +80,10 @@ class DatabaseHelper {
dosageTaken REAL NOT NULL,
unitsTaken REAL NOT NULL DEFAULT 1,
notes TEXT,
syncId TEXT NOT NULL UNIQUE,
lastModified TEXT NOT NULL,
syncStatus TEXT NOT NULL DEFAULT 'pending',
isDeleted INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (supplementId) REFERENCES $supplementsTable (id)
)
''');
@@ -89,6 +101,27 @@ class DatabaseHelper {
FOREIGN KEY (supplementId) REFERENCES $supplementsTable (id)
)
''');
// Sync metadata table for tracking sync operations
await db.execute('''
CREATE TABLE $syncMetadataTable (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
value TEXT NOT NULL,
lastUpdated TEXT NOT NULL
)
''');
// Device info table for conflict resolution
await db.execute('''
CREATE TABLE $deviceInfoTable (
id INTEGER PRIMARY KEY AUTOINCREMENT,
deviceId TEXT NOT NULL UNIQUE,
deviceName TEXT NOT NULL,
lastSyncTime TEXT,
createdAt TEXT NOT NULL
)
''');
}
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
@@ -218,6 +251,77 @@ class DatabaseHelper {
)
''');
}
if (oldVersion < 6) {
// Add sync columns to existing tables
await db.execute('ALTER TABLE $supplementsTable ADD COLUMN syncId TEXT');
await db.execute('ALTER TABLE $supplementsTable ADD COLUMN lastModified TEXT');
await db.execute('ALTER TABLE $supplementsTable ADD COLUMN syncStatus TEXT DEFAULT "pending"');
await db.execute('ALTER TABLE $supplementsTable ADD COLUMN isDeleted INTEGER DEFAULT 0');
await db.execute('ALTER TABLE $intakesTable ADD COLUMN syncId TEXT');
await db.execute('ALTER TABLE $intakesTable ADD COLUMN lastModified TEXT');
await db.execute('ALTER TABLE $intakesTable ADD COLUMN syncStatus TEXT DEFAULT "pending"');
await db.execute('ALTER TABLE $intakesTable ADD COLUMN isDeleted INTEGER DEFAULT 0');
// Generate sync IDs and timestamps for existing records
final supplements = await db.query(supplementsTable);
for (final supplement in supplements) {
if (supplement['syncId'] == null) {
final now = DateTime.now().toIso8601String();
await db.update(
supplementsTable,
{
'syncId': 'sync-${supplement['id']}-${DateTime.now().millisecondsSinceEpoch}',
'lastModified': now,
'syncStatus': 'pending',
'isDeleted': 0,
},
where: 'id = ?',
whereArgs: [supplement['id']],
);
}
}
final intakes = await db.query(intakesTable);
for (final intake in intakes) {
if (intake['syncId'] == null) {
final now = DateTime.now().toIso8601String();
await db.update(
intakesTable,
{
'syncId': 'sync-${intake['id']}-${DateTime.now().millisecondsSinceEpoch}',
'lastModified': now,
'syncStatus': 'pending',
'isDeleted': 0,
},
where: 'id = ?',
whereArgs: [intake['id']],
);
}
}
// Create sync metadata table
await db.execute('''
CREATE TABLE $syncMetadataTable (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
value TEXT NOT NULL,
lastUpdated TEXT NOT NULL
)
''');
// Create device info table
await db.execute('''
CREATE TABLE $deviceInfoTable (
id INTEGER PRIMARY KEY AUTOINCREMENT,
deviceId TEXT NOT NULL UNIQUE,
deviceName TEXT NOT NULL,
lastSyncTime TEXT,
createdAt TEXT NOT NULL
)
''');
}
}
// Supplement CRUD operations
@@ -230,8 +334,8 @@ class DatabaseHelper {
Database db = await database;
List<Map<String, dynamic>> maps = await db.query(
supplementsTable,
where: 'isActive = ?',
whereArgs: [1],
where: 'isActive = ? AND isDeleted = ?',
whereArgs: [1, 0],
orderBy: 'name ASC',
);
return List.generate(maps.length, (i) => Supplement.fromMap(maps[i]));
@@ -241,8 +345,8 @@ class DatabaseHelper {
Database db = await database;
List<Map<String, dynamic>> maps = await db.query(
supplementsTable,
where: 'isActive = ?',
whereArgs: [0],
where: 'isActive = ? AND isDeleted = ?',
whereArgs: [0, 0],
orderBy: 'name ASC',
);
return List.generate(maps.length, (i) => Supplement.fromMap(maps[i]));
@@ -295,7 +399,43 @@ class DatabaseHelper {
Database db = await database;
return await db.update(
supplementsTable,
{'isActive': 0},
{
'isActive': 0,
'isDeleted': 1,
'lastModified': DateTime.now().toIso8601String(),
},
where: 'id = ?',
whereArgs: [id],
);
}
Future<int> permanentlyDeleteSupplement(int id) async {
Database db = await database;
// For sync compatibility, we should mark as deleted rather than completely removing
// This prevents the supplement from reappearing during sync
// First mark all related intakes as deleted
await db.update(
intakesTable,
{
'isDeleted': 1,
'lastModified': DateTime.now().toIso8601String(),
'syncStatus': RecordSyncStatus.modified.name,
},
where: 'supplementId = ? AND isDeleted = ?',
whereArgs: [id, 0],
);
// Then mark the supplement as deleted instead of removing it completely
return await db.update(
supplementsTable,
{
'isDeleted': 1,
'isActive': 0, // Also ensure it's archived
'lastModified': DateTime.now().toIso8601String(),
'syncStatus': RecordSyncStatus.modified.name,
},
where: 'id = ?',
whereArgs: [id],
);
@@ -307,6 +447,36 @@ class DatabaseHelper {
return await db.insert(intakesTable, intake.toMap());
}
Future<int> deleteIntake(int id) async {
Database db = await database;
return await db.update(
intakesTable,
{
'isDeleted': 1,
'lastModified': DateTime.now().toIso8601String(),
},
where: 'id = ?',
whereArgs: [id],
);
}
Future<int> permanentlyDeleteIntake(int id) async {
Database db = await database;
// For sync compatibility, mark as deleted rather than completely removing
// This prevents the intake from reappearing during sync
return await db.update(
intakesTable,
{
'isDeleted': 1,
'lastModified': DateTime.now().toIso8601String(),
'syncStatus': RecordSyncStatus.modified.name,
},
where: 'id = ?',
whereArgs: [id],
);
}
Future<List<SupplementIntake>> getIntakesForDate(DateTime date) async {
Database db = await database;
String startDate = DateTime(date.year, date.month, date.day).toIso8601String();
@@ -314,8 +484,8 @@ class DatabaseHelper {
List<Map<String, dynamic>> maps = await db.query(
intakesTable,
where: 'takenAt >= ? AND takenAt <= ?',
whereArgs: [startDate, endDate],
where: 'takenAt >= ? AND takenAt <= ? AND isDeleted = ?',
whereArgs: [startDate, endDate, 0],
orderBy: 'takenAt DESC',
);
return List.generate(maps.length, (i) => SupplementIntake.fromMap(maps[i]));
@@ -328,8 +498,8 @@ class DatabaseHelper {
List<Map<String, dynamic>> maps = await db.query(
intakesTable,
where: 'takenAt >= ? AND takenAt <= ?',
whereArgs: [startDate, endDate],
where: 'takenAt >= ? AND takenAt <= ? AND isDeleted = ?',
whereArgs: [startDate, endDate, 0],
orderBy: 'takenAt DESC',
);
return List.generate(maps.length, (i) => SupplementIntake.fromMap(maps[i]));
@@ -347,9 +517,9 @@ class DatabaseHelper {
s.unitType as supplementUnitType
FROM $intakesTable i
JOIN $supplementsTable s ON i.supplementId = s.id
WHERE i.takenAt >= ? AND i.takenAt <= ?
WHERE i.takenAt >= ? AND i.takenAt <= ? AND i.isDeleted = ?
ORDER BY i.takenAt DESC
''', [startDate, endDate]);
''', [startDate, endDate, 0]);
return result;
}
@@ -366,22 +536,13 @@ class DatabaseHelper {
s.unitType as supplementUnitType
FROM $intakesTable i
JOIN $supplementsTable s ON i.supplementId = s.id
WHERE i.takenAt >= ? AND i.takenAt <= ?
WHERE i.takenAt >= ? AND i.takenAt <= ? AND i.isDeleted = ?
ORDER BY i.takenAt DESC
''', [startDate, endDate]);
''', [startDate, endDate, 0]);
return result;
}
Future<int> deleteIntake(int id) async {
Database db = await database;
return await db.delete(
intakesTable,
where: 'id = ?',
whereArgs: [id],
);
}
// Notification tracking methods
Future<int> trackNotification({
required int notificationId,
@@ -469,4 +630,130 @@ class DatabaseHelper {
whereArgs: [supplementId],
);
}
// Sync metadata operations
Future<void> setSyncMetadata(String key, String value) async {
Database db = await database;
await db.rawInsert('''
INSERT OR REPLACE INTO $syncMetadataTable (key, value, lastUpdated)
VALUES (?, ?, ?)
''', [key, value, DateTime.now().toIso8601String()]);
}
Future<String?> getSyncMetadata(String key) async {
Database db = await database;
List<Map<String, dynamic>> result = await db.query(
syncMetadataTable,
where: 'key = ?',
whereArgs: [key],
);
return result.isNotEmpty ? result.first['value'] : null;
}
Future<void> deleteSyncMetadata(String key) async {
Database db = await database;
await db.delete(
syncMetadataTable,
where: 'key = ?',
whereArgs: [key],
);
}
// Device info operations
Future<void> setDeviceInfo(String deviceId, String deviceName) async {
Database db = await database;
await db.rawInsert('''
INSERT OR REPLACE INTO $deviceInfoTable (deviceId, deviceName, lastSyncTime, createdAt)
VALUES (?, ?, ?, ?)
''', [deviceId, deviceName, null, DateTime.now().toIso8601String()]);
}
Future<void> updateLastSyncTime(String deviceId) async {
Database db = await database;
await db.update(
deviceInfoTable,
{'lastSyncTime': DateTime.now().toIso8601String()},
where: 'deviceId = ?',
whereArgs: [deviceId],
);
}
Future<Map<String, dynamic>?> getDeviceInfo(String deviceId) async {
Database db = await database;
List<Map<String, dynamic>> result = await db.query(
deviceInfoTable,
where: 'deviceId = ?',
whereArgs: [deviceId],
);
return result.isNotEmpty ? result.first : null;
}
// Sync-specific queries
Future<List<Supplement>> getModifiedSupplements() async {
Database db = await database;
List<Map<String, dynamic>> maps = await db.query(
supplementsTable,
where: 'syncStatus IN (?, ?)',
whereArgs: [RecordSyncStatus.pending.name, RecordSyncStatus.modified.name],
orderBy: 'lastModified ASC',
);
return List.generate(maps.length, (i) => Supplement.fromMap(maps[i]));
}
Future<List<SupplementIntake>> getModifiedIntakes() async {
Database db = await database;
List<Map<String, dynamic>> maps = await db.query(
intakesTable,
where: 'syncStatus IN (?, ?)',
whereArgs: [RecordSyncStatus.pending.name, RecordSyncStatus.modified.name],
orderBy: 'lastModified ASC',
);
return List.generate(maps.length, (i) => SupplementIntake.fromMap(maps[i]));
}
Future<void> markSupplementAsSynced(String syncId) async {
Database db = await database;
await db.update(
supplementsTable,
{'syncStatus': RecordSyncStatus.synced.name},
where: 'syncId = ?',
whereArgs: [syncId],
);
}
Future<void> markIntakeAsSynced(String syncId) async {
Database db = await database;
await db.update(
intakesTable,
{'syncStatus': RecordSyncStatus.synced.name},
where: 'syncId = ?',
whereArgs: [syncId],
);
}
Future<Supplement?> getSupplementBySyncId(String syncId) async {
Database db = await database;
List<Map<String, dynamic>> maps = await db.query(
supplementsTable,
where: 'syncId = ?',
whereArgs: [syncId],
);
if (maps.isNotEmpty) {
return Supplement.fromMap(maps.first);
}
return null;
}
Future<SupplementIntake?> getIntakeBySyncId(String syncId) async {
Database db = await database;
List<Map<String, dynamic>> maps = await db.query(
intakesTable,
where: 'syncId = ?',
whereArgs: [syncId],
);
if (maps.isNotEmpty) {
return SupplementIntake.fromMap(maps.first);
}
return null;
}
}

View File

@@ -0,0 +1,533 @@
import 'dart:io' as io;
import 'package:flutter/foundation.dart';
import 'package:path/path.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sqflite/sqflite.dart';
import 'package:supplements/logging.dart';
import 'package:webdav_client/webdav_client.dart';
import '../models/supplement.dart';
import '../models/supplement_intake.dart';
import 'database_helper.dart';
enum SyncStatus {
idle,
downloading,
merging,
uploading,
completed,
error,
}
// Legacy record-level sync status for models
enum RecordSyncStatus {
pending,
synced,
modified,
}
class DatabaseSyncService {
static const String _remoteDbFileName = 'supplements.db';
// SharedPreferences keys for persistence
static const String _keyServerUrl = 'sync_server_url';
static const String _keyUsername = 'sync_username';
static const String _keyPassword = 'sync_password';
static const String _keyRemotePath = 'sync_remote_path';
Client? _client;
String? _remotePath;
// Store configuration values
String? _serverUrl;
String? _username;
String? _password;
String? _configuredRemotePath;
final DatabaseHelper _databaseHelper = DatabaseHelper.instance;
SyncStatus _status = SyncStatus.idle;
String? _lastError;
DateTime? _lastSyncTime;
// Getters
SyncStatus get status => _status;
String? get lastError => _lastError;
DateTime? get lastSyncTime => _lastSyncTime;
bool get isConfigured => _client != null;
// Configuration getters
String? get serverUrl => _serverUrl;
String? get username => _username;
String? get password => _password;
String? get remotePath => _configuredRemotePath;
// Callbacks for UI updates
Function(SyncStatus)? onStatusChanged;
Function(String)? onError;
Function()? onSyncCompleted;
DatabaseSyncService() {
loadSavedConfiguration();
}
// Load saved configuration from SharedPreferences
Future<void> loadSavedConfiguration() async {
try {
final prefs = await SharedPreferences.getInstance();
_serverUrl = prefs.getString(_keyServerUrl);
_username = prefs.getString(_keyUsername);
_password = prefs.getString(_keyPassword);
_configuredRemotePath = prefs.getString(_keyRemotePath);
// If we have saved configuration, set up the client
if (_serverUrl != null && _username != null && _password != null && _configuredRemotePath != null) {
_remotePath = _configuredRemotePath!.endsWith('/') ? _configuredRemotePath : '$_configuredRemotePath/';
_client = newClient(
_serverUrl!,
user: _username!,
password: _password!,
debug: kDebugMode,
);
}
} catch (e) {
if (kDebugMode) {
printLog('Error loading saved sync configuration: $e');
}
}
}
// Save configuration to SharedPreferences
Future<void> _saveConfiguration() async {
try {
final prefs = await SharedPreferences.getInstance();
if (_serverUrl != null) await prefs.setString(_keyServerUrl, _serverUrl!);
if (_username != null) await prefs.setString(_keyUsername, _username!);
if (_password != null) await prefs.setString(_keyPassword, _password!);
if (_configuredRemotePath != null) await prefs.setString(_keyRemotePath, _configuredRemotePath!);
} catch (e) {
if (kDebugMode) {
printLog('Error saving sync configuration: $e');
}
}
}
void configure({
required String serverUrl,
required String username,
required String password,
required String remotePath,
}) {
// Store configuration values
_serverUrl = serverUrl;
_username = username;
_password = password;
_configuredRemotePath = remotePath;
_remotePath = remotePath.endsWith('/') ? remotePath : '$remotePath/';
_client = newClient(
serverUrl,
user: username,
password: password,
debug: kDebugMode,
);
// Save configuration to persistent storage
_saveConfiguration();
}
Future<bool> testConnection() async {
if (_client == null) return false;
try {
await _client!.ping();
return true;
} catch (e) {
if (kDebugMode) {
printLog('Connection test failed: $e');
}
return false;
}
}
Future<void> syncDatabase() async {
if (_client == null) {
throw Exception('Sync not configured');
}
_setStatus(SyncStatus.downloading);
try {
// Step 1: Download remote database (if it exists)
final remoteDbPath = await _downloadRemoteDatabase();
// Step 2: Merge databases
_setStatus(SyncStatus.merging);
await _mergeDatabases(remoteDbPath);
// Step 3: Upload merged database
_setStatus(SyncStatus.uploading);
await _uploadLocalDatabase();
// Step 4: Cleanup - for now we'll skip cleanup to avoid file issues
// TODO: Implement proper cleanup once file operations are working
_lastSyncTime = DateTime.now();
_setStatus(SyncStatus.completed);
onSyncCompleted?.call();
} catch (e) {
_lastError = e.toString();
_setStatus(SyncStatus.error);
onError?.call(_lastError!);
if (kDebugMode) {
printLog('Sync failed: $e');
}
rethrow;
}
}
Future<String?> _downloadRemoteDatabase() async {
try {
// Check if remote database exists
final files = await _client!.readDir(_remotePath!);
final remoteDbExists = files.any((file) => file.name == _remoteDbFileName);
if (!remoteDbExists) {
if (kDebugMode) {
printLog('No remote database found, will upload local database');
}
return null;
}
if (kDebugMode) {
printLog('Remote database found, downloading...');
}
// Download the remote database
final remoteDbBytes = await _client!.read('$_remotePath$_remoteDbFileName');
// Create a temporary file path for the downloaded database
final tempDir = await getDatabasesPath();
final tempDbPath = join(tempDir, 'remote_supplements.db');
// Write the downloaded database to a temporary file
final tempFile = io.File(tempDbPath);
await tempFile.writeAsBytes(remoteDbBytes);
if (kDebugMode) {
printLog('Downloaded remote database (${remoteDbBytes.length} bytes) to: $tempDbPath');
}
return tempDbPath;
} catch (e) {
if (kDebugMode) {
printLog('Failed to download remote database: $e');
}
return null;
}
}
Future<void> _mergeDatabases(String? remoteDbPath) async {
if (remoteDbPath == null) {
if (kDebugMode) {
printLog('No remote database to merge');
}
return;
}
if (kDebugMode) {
printLog('Starting database merge from: $remoteDbPath');
}
final localDb = await _databaseHelper.database;
final remoteDb = await openDatabase(remoteDbPath, readOnly: true);
try {
// Check what tables exist in remote database
if (kDebugMode) {
final tables = await remoteDb.rawQuery("SELECT name FROM sqlite_master WHERE type='table'");
printLog('Remote database tables: ${tables.map((t) => t['name']).toList()}');
// Count records in each table
try {
final supplementCount = await remoteDb.rawQuery('SELECT COUNT(*) as count FROM supplements');
printLog('Remote supplements count: ${supplementCount.first['count']}');
} catch (e) {
printLog('Error counting supplements: $e');
}
try {
final intakeCount = await remoteDb.rawQuery('SELECT COUNT(*) as count FROM supplement_intakes');
printLog('Remote intakes count: ${intakeCount.first['count']}');
} catch (e) {
printLog('Error counting intakes: $e');
}
}
// Merge supplements
await _mergeSupplements(localDb, remoteDb);
// Merge intakes
await _mergeIntakes(localDb, remoteDb);
if (kDebugMode) {
printLog('Database merge completed successfully');
}
} finally {
await remoteDb.close();
}
}
Future<void> _mergeSupplements(Database localDb, Database remoteDb) async {
if (kDebugMode) {
printLog('Starting supplement merge...');
}
// Get all supplements from remote database
final remoteMaps = await remoteDb.query('supplements');
final remoteSupplements =
remoteMaps.map((map) => Supplement.fromMap(map)).toList();
if (kDebugMode) {
printLog(
'Found ${remoteSupplements.length} supplements in remote database');
for (final supplement in remoteSupplements) {
printLog(
'Remote supplement: ${supplement.name} (syncId: ${supplement.syncId}, deleted: ${supplement.isDeleted})');
}
}
for (final remoteSupplement in remoteSupplements) {
if (remoteSupplement.syncId.isEmpty) {
if (kDebugMode) {
printLog(
'Skipping supplement ${remoteSupplement.name} - no syncId');
}
continue;
}
// Find existing supplement by syncId
final existingMaps = await localDb.query(
'supplements',
where: 'syncId = ?',
whereArgs: [remoteSupplement.syncId],
);
if (existingMaps.isEmpty) {
// New supplement from remote - insert it
if (!remoteSupplement.isDeleted) {
// Manually create a new map without the id to ensure it's null
final mapToInsert = remoteSupplement.toMap();
mapToInsert.remove('id');
await localDb.insert('supplements', mapToInsert);
if (kDebugMode) {
printLog(
'✓ Inserted new supplement: ${remoteSupplement.name}');
}
} else {
if (kDebugMode) {
printLog(
'Skipping deleted supplement: ${remoteSupplement.name}');
}
}
} else {
// Existing supplement - update if remote is newer
final existingSupplement = Supplement.fromMap(existingMaps.first);
if (remoteSupplement.lastModified
.isAfter(existingSupplement.lastModified)) {
final supplementToUpdate =
remoteSupplement.copyWith(id: existingSupplement.id);
await localDb.update(
'supplements',
supplementToUpdate.toMap(),
where: 'id = ?',
whereArgs: [existingSupplement.id],
);
if (kDebugMode) {
printLog(
'✓ Updated supplement: ${remoteSupplement.name}');
}
} else {
if (kDebugMode) {
printLog(
'Local supplement ${remoteSupplement.name} is newer, keeping local version');
}
}
}
}
if (kDebugMode) {
printLog('Supplement merge completed');
}
}
Future<void> _mergeIntakes(Database localDb, Database remoteDb) async {
if (kDebugMode) {
printLog('Starting intake merge...');
}
// Get all intakes from remote database
final remoteMaps = await remoteDb.query('supplement_intakes');
final remoteIntakes = remoteMaps.map((map) => SupplementIntake.fromMap(map)).toList();
if (kDebugMode) {
printLog('Found ${remoteIntakes.length} intakes in remote database');
}
for (final remoteIntake in remoteIntakes) {
if (remoteIntake.syncId.isEmpty) {
if (kDebugMode) {
printLog('Skipping intake - no syncId');
}
continue;
}
// Find existing intake by syncId
final existingMaps = await localDb.query(
'supplement_intakes',
where: 'syncId = ?',
whereArgs: [remoteIntake.syncId],
);
if (existingMaps.isEmpty) {
// New intake from remote - need to find local supplement ID
if (!remoteIntake.isDeleted) {
final localSupplementId = await _findLocalSupplementId(localDb, remoteIntake.supplementId, remoteDb);
if (localSupplementId != null) {
final intakeToInsert = remoteIntake.copyWith(
id: null,
supplementId: localSupplementId,
);
await localDb.insert('supplement_intakes', intakeToInsert.toMap());
if (kDebugMode) {
printLog('✓ Inserted new intake: ${remoteIntake.syncId}');
}
} else {
if (kDebugMode) {
printLog('Could not find local supplement for intake ${remoteIntake.syncId}');
}
}
} else {
if (kDebugMode) {
printLog('Skipping deleted intake: ${remoteIntake.syncId}');
}
}
} else {
// Existing intake - update if remote is newer
final existingIntake = SupplementIntake.fromMap(existingMaps.first);
if (remoteIntake.lastModified.isAfter(existingIntake.lastModified)) {
final intakeToUpdate = remoteIntake.copyWith(id: existingIntake.id);
await localDb.update(
'supplement_intakes',
intakeToUpdate.toMap(),
where: 'id = ?',
whereArgs: [existingIntake.id],
);
if (kDebugMode) {
printLog('✓ Updated intake: ${remoteIntake.syncId}');
}
} else {
if (kDebugMode) {
printLog('Local intake ${remoteIntake.syncId} is newer, keeping local version');
}
}
}
}
if (kDebugMode) {
printLog('Intake merge completed');
}
}
Future<int?> _findLocalSupplementId(Database localDb, int remoteSupplementId, Database remoteDb) async {
// Get the remote supplement
final remoteSupplementMaps = await remoteDb.query(
'supplements',
where: 'id = ?',
whereArgs: [remoteSupplementId],
);
if (remoteSupplementMaps.isEmpty) return null;
final remoteSupplement = Supplement.fromMap(remoteSupplementMaps.first);
// Find the local supplement with the same syncId
final localSupplementMaps = await localDb.query(
'supplements',
where: 'syncId = ?',
whereArgs: [remoteSupplement.syncId],
);
if (localSupplementMaps.isEmpty) return null;
return localSupplementMaps.first['id'] as int;
}
Future<void> _uploadLocalDatabase() async {
try {
// Get the local database path
final localDb = await _databaseHelper.database;
final dbPath = localDb.path;
if (kDebugMode) {
printLog('Reading database from: $dbPath');
}
// Read the database file
final dbFile = io.File(dbPath);
if (!await dbFile.exists()) {
throw Exception('Database file not found at: $dbPath');
}
final dbBytes = await dbFile.readAsBytes();
if (kDebugMode) {
printLog('Database file size: ${dbBytes.length} bytes');
}
if (dbBytes.isEmpty) {
throw Exception('Database file is empty');
}
// Ensure remote directory exists
try {
await _client!.readDir(_remotePath!);
} catch (e) {
if (kDebugMode) {
printLog('Creating remote directory: $_remotePath');
}
await _client!.mkdir(_remotePath!);
}
// Upload the database file
final remoteUrl = '$_remotePath$_remoteDbFileName';
await _client!.write(remoteUrl, dbBytes);
if (kDebugMode) {
printLog('Successfully uploaded database (${dbBytes.length} bytes) to: $remoteUrl');
}
} catch (e) {
if (kDebugMode) {
printLog('Failed to upload database: $e');
}
rethrow;
}
}
void _setStatus(SyncStatus status) {
_status = status;
onStatusChanged?.call(status);
}
void clearError() {
_lastError = null;
}
}

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,342 @@
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:shared_preferences/shared_preferences.dart';
import 'package:supplements/logging.dart';
import 'package:supplements/services/simple_notification_service.dart';
import '../models/supplement.dart';
import '../providers/supplement_provider.dart';
import '../widgets/dialogs/bulk_take_dialog.dart';
import '../widgets/dialogs/take_supplement_dialog.dart';
/// Centralizes routing from notification actions/taps to in-app UI.
/// Handles both foreground/background taps and terminated-launch scenarios.
class NotificationRouter {
NotificationRouter._internal();
static final NotificationRouter instance = NotificationRouter._internal();
GlobalKey<NavigatorState>? _navigatorKey;
void initialize(GlobalKey<NavigatorState> navigatorKey) {
_navigatorKey = navigatorKey;
}
Future<void> handleNotificationResponse(NotificationResponse response) async {
final payloadMap = _decodePayload(response.payload);
final actionId = response.actionId;
printLog('🔔 handleNotificationResponse: actionId=$actionId payload=${response.payload} map=$payloadMap');
printLog('🔔 handleNotificationResponse: Received actionId: $actionId');
printLog('🔔 handleNotificationResponse: Decoded payloadMap: $payloadMap');
// Cancel retry notifications for any interaction (take, snooze, or tap)
await _cancelRetryNotificationsForResponse(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!;
if (!context.mounted) return;
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();
if (!context.mounted) return;
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);
if (!context.mounted) return;
} 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);
if (!context.mounted) return;
}
} 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));
SupplementProvider? provider;
if (ready) {
final ctx = _navigatorKey?.currentContext;
if (ctx != null && ctx.mounted) {
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) {
if (!ctx.mounted) continue;
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) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
});
}
/// Cancel retry notifications when user responds to any notification.
/// This prevents redundant notifications after user interaction.
Future<void> _cancelRetryNotificationsForResponse(Map<String, dynamic>? payloadMap) async {
if (payloadMap == null) return;
try {
final type = payloadMap['type'];
if (type == 'single') {
// For single notifications, we need to find the time slot to cancel retries
// We can extract this from the meta if it's a retry, or find it from the supplement
final meta = payloadMap['meta'] as Map<String, dynamic>?;
if (meta != null && meta['originalTime'] != null) {
// This is a retry notification, cancel remaining retries for the original time
final originalTime = meta['originalTime'] as String;
await SimpleNotificationService.instance.cancelRetryNotificationsForTimeSlot(originalTime);
printLog('🚫 Cancelled retries for original time slot: $originalTime');
} else {
// This is an original notification, find the time slot from the supplement
final supplementId = payloadMap['id'];
if (supplementId is int) {
// We need to find which time slots this supplement has and cancel retries for all of them
// For now, we'll use the broader cancellation method
await SimpleNotificationService.instance.cancelRetryNotificationsForSupplement(supplementId);
printLog('🚫 Cancelled retries for supplement ID: $supplementId');
}
}
} else if (type == 'group') {
// For group notifications, we have the time key directly
final timeKey = payloadMap['time'] as String?;
if (timeKey != null) {
await SimpleNotificationService.instance.cancelRetryNotificationsForTimeSlot(timeKey);
printLog('🚫 Cancelled retries for time slot: $timeKey');
}
}
} catch (e) {
printLog('⚠️ Failed to cancel retry notifications: $e');
}
}
}

View File

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

View File

@@ -0,0 +1,763 @@
import 'dart:convert';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:supplements/logging.dart';
import 'package:supplements/services/notification_debug_store.dart';
import 'package:supplements/services/notification_router.dart';
import 'package:timezone/data/latest.dart' as tzdata;
import 'package:timezone/timezone.dart' as tz;
import '../models/supplement.dart';
import '../providers/settings_provider.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.
/// - Retry IDs: 50000 + (hour*60 + minute)*100 + retryIndex.
/// - Stable and predictable for cancel/update operations.
Future<void> scheduleDailyGroupedReminders(
List<Supplement> supplements, {
SettingsProvider? settingsProvider,
}) 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 : null,
timeKey: isSingle ? null : timeKey,
),
);
printLog('✅ Scheduled group $timeKey with ID $id');
// Schedule retry notifications if enabled
if (settingsProvider != null && settingsProvider.notificationRetryEnabled) {
await _scheduleRetryNotifications(
timeKey: timeKey,
supplements: items,
isSingle: isSingle,
title: title,
body: body,
payloadStr: payloadStr,
retryCount: settingsProvider.notificationRetryCount,
retryDelayMinutes: settingsProvider.notificationRetryDelayMinutes,
);
}
}
// Log what the system reports as pending
try {
final pending = await _plugin.pendingNotificationRequests();
printLog('📋 Pending notifications after scheduling: ${pending.length}');
for (final p in pending) {
printLog(' - ID=${p.id}, Title=${p.title}, BodyLen=${p.body?.length ?? 0}');
}
} catch (e) {
printLog('⚠️ Could not fetch pending notifications: $e');
}
}
/// Convenience to schedule grouped reminders for today and tomorrow.
///
/// For iOS's 64 limit, we stick to one day for recurring (matchDateTimeComponents)
/// which already repeats every day without needing to schedule future dates.
/// If you want an extra safety net, you could schedule tomorrow's one-offs,
/// but with daily components this is unnecessary and risks hitting iOS limits.
Future<void> scheduleDailyGroupedRemindersSafe(
List<Supplement> supplements, {
SettingsProvider? settingsProvider,
}) async {
// For now, just schedule today's recurring groups.
await scheduleDailyGroupedReminders(supplements, settingsProvider: settingsProvider);
}
/// 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;
}
/// Schedule retry notifications for a specific time group.
Future<void> _scheduleRetryNotifications({
required String timeKey,
required List<Supplement> supplements,
required bool isSingle,
required String title,
required String body,
required String payloadStr,
required int retryCount,
required int retryDelayMinutes,
}) async {
if (retryCount <= 0) return;
final parts = timeKey.split(':');
final hour = int.parse(parts[0]);
final minute = int.parse(parts[1]);
final baseTime = _nextInstanceOfTime(hour, minute);
for (int retryIndex = 1; retryIndex <= retryCount; retryIndex++) {
final retryDelay = Duration(minutes: retryDelayMinutes * retryIndex);
final retryTime = baseTime.add(retryDelay);
final retryId = 50000 + ((hour * 60) + minute) * 100 + retryIndex;
// Parse and modify payload to mark as retry
Map<String, dynamic> retryPayload;
try {
retryPayload = Map<String, dynamic>.from(jsonDecode(payloadStr));
retryPayload['meta'] = {
'kind': 'retry',
'originalTime': timeKey,
'retryIndex': retryIndex,
'retryOf': 40000 + (hour * 60) + minute,
};
} catch (e) {
retryPayload = {
'type': isSingle ? 'single' : 'group',
if (isSingle) 'id': supplements.first.id else 'time': timeKey,
'meta': {
'kind': 'retry',
'originalTime': timeKey,
'retryIndex': retryIndex,
'retryOf': 40000 + (hour * 60) + minute,
},
};
}
final retryPayloadStr = jsonEncode(retryPayload);
final retryTitle = 'Reminder: $title';
final androidDetails = AndroidNotificationDetails(
_channelDailyId,
_channelDailyName,
channelDescription: _channelDailyDescription,
importance: Importance.high,
priority: Priority.high,
styleInformation: BigTextStyleInformation(
body,
contentTitle: retryTitle,
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,
)
else
AndroidNotificationAction(
'snooze_group',
'Snooze',
showsUserInterface: false,
),
],
);
final iosDetails = DarwinNotificationDetails(
categoryIdentifier: isSingle ? 'single' : 'group',
);
await _plugin.zonedSchedule(
retryId,
retryTitle,
isSingle ? body : 'Tap to see details',
retryTime,
NotificationDetails(
android: androidDetails,
iOS: iosDetails,
),
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
payload: retryPayloadStr,
);
// Log to debug store
final createdAtMs = DateTime.now().millisecondsSinceEpoch;
await NotificationDebugStore.instance.add(
NotificationLogEntry(
id: retryId,
kind: 'retry',
type: isSingle ? 'single' : 'group',
whenEpochMs: retryTime.millisecondsSinceEpoch,
createdAtEpochMs: createdAtMs,
title: retryTitle,
payload: retryPayloadStr,
singleId: isSingle ? supplements.first.id : null,
timeKey: isSingle ? null : timeKey,
),
);
printLog('🔄 Scheduled retry $retryIndex/$retryCount for $timeKey (ID: $retryId) at $retryTime');
}
}
/// Cancel retry notifications for a specific time slot.
/// This should be called when a user responds to a notification.
Future<void> cancelRetryNotificationsForTimeSlot(String timeKey) async {
if (!_initialized) {
await initialize();
}
final parts = timeKey.split(':');
if (parts.length != 2) return;
final hour = int.tryParse(parts[0]);
final minute = int.tryParse(parts[1]);
if (hour == null || minute == null) return;
// Calculate base retry ID range for this time slot
final baseRetryId = 50000 + ((hour * 60) + minute) * 100;
// Cancel up to 10 possible retries (generous upper bound)
for (int retryIndex = 1; retryIndex <= 10; retryIndex++) {
final retryId = baseRetryId + retryIndex;
await _plugin.cancel(retryId);
printLog('🚫 Cancelled retry notification ID: $retryId for time slot $timeKey');
}
}
/// Cancel retry notifications for a specific supplement ID.
/// This iterates through all possible time slots.
Future<void> cancelRetryNotificationsForSupplement(int supplementId) async {
if (!_initialized) {
await initialize();
}
// Cancel retries for all possible time slots (24 hours * 60 minutes)
for (int hour = 0; hour < 24; hour++) {
for (int minute = 0; minute < 60; minute += 5) { // Assume 5-minute intervals
final baseRetryId = 50000 + ((hour * 60) + minute) * 100;
for (int retryIndex = 1; retryIndex <= 10; retryIndex++) {
final retryId = baseRetryId + retryIndex;
await _plugin.cancel(retryId);
}
}
}
printLog('🚫 Cancelled all retry notifications for supplement ID: $supplementId');
}
}

View File

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.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withValues(alpha: 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.withValues(alpha: 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,245 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shadcn_ui/shadcn_ui.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 showShadDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) {
return ShadDialog(
title: Text('Take ${supplement.name}'),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: ShadInput(
controller: unitsController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
placeholder: Text('Number of ${supplement.unitType}'),
onChanged: (value) => setState(() {}),
),
),
],
),
const SizedBox(height: 8),
ShadCard(
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Total dosage:',
style: TextStyle(
fontSize: 12,
color: ShadTheme.of(context).colorScheme.foreground,
),
),
Text(
supplement.ingredientsDisplay,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: ShadTheme.of(context).colorScheme.foreground,
),
),
],
),
),
),
const SizedBox(height: 16),
if (!hideTime) ...[
ShadCard(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.access_time,
size: 16,
color: ShadTheme.of(context).colorScheme.primary,
),
const SizedBox(width: 6),
Text(
'When did you take it?',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: ShadTheme.of(context).colorScheme.primary,
),
),
],
),
const SizedBox(height: 8),
ShadRadioGroupFormField<bool>(
initialValue: useCustomTime,
onChanged: (value) => setState(() => useCustomTime = value!),
items: [
ShadRadio(
value: false,
label: const Text('Just now', style: TextStyle(fontSize: 12)),
),
ShadRadio(
value: true,
label: const Text('Custom time', style: TextStyle(fontSize: 12)),
),
],
),
if (useCustomTime) ...[
const SizedBox(height: 8),
ShadCard(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Column(
children: [
Row(
children: [
Icon(
Icons.calendar_today,
size: 14,
color: ShadTheme.of(context).colorScheme.foreground,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Date: ${selectedDateTime.day}/${selectedDateTime.month}/${selectedDateTime.year}',
style: const TextStyle(fontSize: 12),
),
),
ShadButton.outline(
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),
Row(
children: [
Icon(
Icons.access_time,
size: 14,
color: ShadTheme.of(context).colorScheme.foreground,
),
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),
),
),
ShadButton.outline(
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),
],
ShadInput(
controller: notesController,
placeholder: const Text('Notes (optional)'),
maxLines: 2,
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ShadButton.outline(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
const SizedBox(width: 8),
ShadButton(
onPressed: () {
final unitsTaken = double.tryParse(unitsController.text) ?? supplement.numberOfUnits.toDouble();
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();
ShadToaster.of(context).show(
ShadToast(
title: Text('${supplement.name} recorded!'),
),
);
},
child: const Text('Record'),
),
],
),
],
),
),
);
},
),
);
}

View File

@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
class InfoChip extends StatelessWidget {
final IconData icon;
final String label;
final BuildContext context;
final bool fullWidth;
const InfoChip({
super.key,
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.surfaceContainerHighest.withValues(alpha: 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

@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:popover/popover.dart';
import '../models/supplement.dart';
import '../providers/supplement_provider.dart';
@@ -9,6 +11,9 @@ class SupplementCard extends StatefulWidget {
final VoidCallback onEdit;
final VoidCallback onDelete;
final VoidCallback onArchive;
final VoidCallback onDuplicate;
final VoidCallback? onUndoLastTaken;
final bool showCompletionStatus;
const SupplementCard({
super.key,
@@ -17,6 +22,9 @@ class SupplementCard extends StatefulWidget {
required this.onEdit,
required this.onDelete,
required this.onArchive,
required this.onDuplicate,
this.onUndoLastTaken,
this.showCompletionStatus = true,
});
@override
@@ -26,440 +34,277 @@ class SupplementCard extends StatefulWidget {
class _SupplementCardState extends State<SupplementCard> {
bool _isExpanded = false;
Widget _buildPopoverItem({
required IconData icon,
required String label,
required VoidCallback onTap,
Color? color,
}) {
return InkWell(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(
icon,
size: 18,
color: color ?? Theme.of(context).colorScheme.onSurface,
),
const SizedBox(width: 12),
Text(
label,
style: TextStyle(
fontSize: 14,
color: color ?? Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return Consumer<SupplementProvider>(
builder: (context, provider, child) {
final bool isTakenToday = provider.hasBeenTakenToday(widget.supplement.id!);
final int todayIntakeCount = provider.getTodayIntakeCount(widget.supplement.id!);
final bool isCompletelyTaken = todayIntakeCount >= widget.supplement.frequencyPerDay;
final bool isTakenToday = widget.showCompletionStatus ? provider.hasBeenTakenToday(widget.supplement.id!) : false;
final int todayIntakeCount = widget.showCompletionStatus ? provider.getTodayIntakeCount(widget.supplement.id!) : 0;
final bool isCompletelyTaken = widget.showCompletionStatus ? todayIntakeCount >= widget.supplement.frequencyPerDay : false;
// Get today's intake times for this supplement
final todayIntakes = provider.todayIntakes
// Get today's intake times for this supplement (only if showing completion status)
final todayIntakes = widget.showCompletionStatus ? provider.todayIntakes
.where((intake) => intake['supplement_id'] == widget.supplement.id)
.map((intake) {
final takenAt = DateTime.parse(intake['takenAt']);
final unitsTaken = intake['unitsTaken'] ?? 1.0;
return {
'time': '${takenAt.hour.toString().padLeft(2, '0')}:${takenAt.minute.toString().padLeft(2, '0')}',
'units': unitsTaken,
'units': unitsTaken is int ? unitsTaken.toDouble() : unitsTaken as double,
};
}).toList();
}).toList() : [];
return Card(
margin: const EdgeInsets.only(bottom: 16),
elevation: 3,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: isCompletelyTaken
? Theme.of(context).colorScheme.surface
: isTakenToday
? Theme.of(context).colorScheme.secondaryContainer
: Theme.of(context).colorScheme.surface,
border: Border.all(
color: isCompletelyTaken
? Colors.green.shade600
: isTakenToday
? Theme.of(context).colorScheme.secondary
: Theme.of(context).colorScheme.outline.withOpacity(0.2),
width: 1.5,
),
),
child: Theme(
data: Theme.of(context).copyWith(
dividerColor: Colors.transparent,
),
child: ExpansionTile(
initiallyExpanded: _isExpanded,
onExpansionChanged: (expanded) {
setState(() {
_isExpanded = expanded;
});
},
tilePadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
childrenPadding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
leading: Container(
padding: const EdgeInsets.all(8),
margin: const EdgeInsets.only(bottom: 8),
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: InkWell(
onTap: () {
showPopover(
context: context,
bodyBuilder: (context) => Container(
constraints: const BoxConstraints(maxWidth: 200),
decoration: BoxDecoration(
color: isCompletelyTaken
? Colors.green.shade500
: isTakenToday
? Theme.of(context).colorScheme.secondary
: Theme.of(context).colorScheme.primary.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(
isCompletelyTaken
? Icons.check_circle
: isTakenToday
? Icons.schedule
: Icons.medication,
color: isCompletelyTaken
? Theme.of(context).colorScheme.inverseSurface
: isTakenToday
? Theme.of(context).colorScheme.onSecondary
: Theme.of(context).colorScheme.primary,
size: 20,
),
),
title: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.supplement.name,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: isCompletelyTaken
? Theme.of(context).colorScheme.inverseSurface
: isTakenToday
? Theme.of(context).colorScheme.onSecondaryContainer
: Theme.of(context).colorScheme.onSurface,
),
),
if (widget.supplement.brand != null && widget.supplement.brand!.isNotEmpty)
Text(
widget.supplement.brand!,
style: TextStyle(
fontSize: 12,
color: isCompletelyTaken
? Colors.green.shade200
: isTakenToday
? Theme.of(context).colorScheme.secondary
: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
],
),
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3),
width: 1,
),
// Status badge and take button in collapsed view
if (!_isExpanded) ...[
if (isCompletelyTaken)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.green.shade500,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'Complete',
style: TextStyle(
color: Theme.of(context).colorScheme.inverseSurface,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
)
else ...[
if (isTakenToday)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondary,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'$todayIntakeCount/${widget.supplement.frequencyPerDay}',
style: TextStyle(
color: Theme.of(context).colorScheme.onSecondary,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
ElevatedButton(
onPressed: isCompletelyTaken ? null : widget.onTake,
style: ElevatedButton.styleFrom(
backgroundColor: isCompletelyTaken
? Colors.green.shade500
: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.inverseSurface,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
minimumSize: const Size(60, 32),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text(
isCompletelyTaken ? '' : 'Take',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
),
],
boxShadow: [
BoxShadow(
color: Theme.of(context).colorScheme.shadow.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
],
),
trailing: PopupMenuButton(
padding: EdgeInsets.zero,
icon: Icon(
Icons.more_vert,
color: isCompletelyTaken
? Theme.of(context).colorScheme.inverseSurface
: Theme.of(context).colorScheme.onSurfaceVariant,
),
onSelected: (value) {
switch (value) {
case 'edit':
widget.onEdit();
break;
case 'archive':
widget.onArchive();
break;
case 'delete':
widget.onDelete();
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'edit',
child: Row(
children: [
Icon(Icons.edit),
SizedBox(width: 8),
Text('Edit'),
],
),
),
const PopupMenuItem(
value: 'archive',
child: Row(
children: [
Icon(Icons.archive, color: Colors.orange),
SizedBox(width: 8),
Text('Archive'),
],
),
),
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete, color: Colors.red),
SizedBox(width: 8),
Text('Delete', style: TextStyle(color: Colors.red)),
],
),
),
],
),
children: [
// Today's intake times (if any) - only show in expanded view
if (todayIntakes.isNotEmpty) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isCompletelyTaken
? Colors.green.shade700.withOpacity(0.8)
: Theme.of(context).colorScheme.secondaryContainer.withOpacity(0.7),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isCompletelyTaken
? Colors.green.shade500
: Theme.of(context).colorScheme.secondary,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.onUndoLastTaken != null && isTakenToday)
_buildPopoverItem(
icon: Icons.undo,
label: 'Undo Last Taken',
onTap: () {
Navigator.of(context).pop();
widget.onUndoLastTaken!();
},
color: Colors.orange,
),
_buildPopoverItem(
icon: Icons.edit,
label: 'Edit',
onTap: () {
Navigator.of(context).pop();
widget.onEdit();
},
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.check_circle_outline,
size: 16,
color: isCompletelyTaken
? Colors.green.shade200
: Theme.of(context).colorScheme.onSecondaryContainer,
),
const SizedBox(width: 6),
Text(
'Taken today:',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: isCompletelyTaken
? Colors.green.shade200
: Theme.of(context).colorScheme.onSecondaryContainer,
),
),
],
),
const SizedBox(height: 6),
Wrap(
spacing: 8,
runSpacing: 4,
children: todayIntakes.map((intake) {
final units = intake['units'] as double;
final unitsText = units == 1.0
? '${widget.supplement.unitType}'
: '${units.toStringAsFixed(units % 1 == 0 ? 0 : 1)} ${widget.supplement.unitType}';
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: isCompletelyTaken
? Colors.green.shade600
: Theme.of(context).colorScheme.secondary,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'${intake['time']}$unitsText',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: isCompletelyTaken
? Colors.white
: Theme.of(context).colorScheme.onSecondary,
),
),
);
}).toList(),
),
],
_buildPopoverItem(
icon: Icons.copy,
label: 'Duplicate',
onTap: () {
Navigator.of(context).pop();
widget.onDuplicate();
},
),
),
const SizedBox(height: 16),
],
// Ingredients section
_buildPopoverItem(
icon: Icons.archive,
label: 'Archive',
color: Colors.orange,
onTap: () {
Navigator.of(context).pop();
widget.onArchive();
},
),
_buildPopoverItem(
icon: Icons.delete,
label: 'Delete',
color: Colors.red,
onTap: () {
Navigator.of(context).pop();
widget.onDelete();
},
),
],
),
),
direction: PopoverDirection.bottom,
width: 180,
height: null,
arrowHeight: 0,
arrowWidth: 0,
backgroundColor: Colors.transparent,
shadow: const [],
);
},
borderRadius: BorderRadius.circular(12),
splashColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.4),
highlightColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: widget.showCompletionStatus ? (isCompletelyTaken
? Theme.of(context).colorScheme.surface
: isTakenToday
? Theme.of(context).colorScheme.secondaryContainer
: Theme.of(context).colorScheme.surface) : Theme.of(context).colorScheme.surface,
border: Border.all(
color: widget.showCompletionStatus ? (isCompletelyTaken
? Colors.green.shade600
: isTakenToday
? Theme.of(context).colorScheme.secondary
: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)) : Theme.of(context).colorScheme.outline.withValues(alpha: 0.2),
width: 1,
),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
color: widget.showCompletionStatus ? (isCompletelyTaken
? Colors.green.shade500
: isTakenToday
? Theme.of(context).colorScheme.secondary
: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1)) : Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(
widget.showCompletionStatus ? (isCompletelyTaken
? Icons.check_circle
: isTakenToday
? Icons.schedule
: Icons.medication) : Icons.medication,
color: widget.showCompletionStatus ? (isCompletelyTaken
? Theme.of(context).colorScheme.inverseSurface
: isTakenToday
? Theme.of(context).colorScheme.onSecondary
: Theme.of(context).colorScheme.primary) : Theme.of(context).colorScheme.primary,
size: 18,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Ingredients per ${widget.supplement.unitType}:',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 4,
children: widget.supplement.ingredients.map((ingredient) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.primary.withOpacity(0.3),
),
),
Row(
children: [
Expanded(
child: Text(
'${ingredient.name} ${ingredient.amount}${ingredient.unit}',
widget.supplement.name,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.primary,
fontSize: 14,
fontWeight: FontWeight.bold,
color: widget.showCompletionStatus ? (isCompletelyTaken
? Theme.of(context).colorScheme.inverseSurface
: isTakenToday
? Theme.of(context).colorScheme.onSecondaryContainer
: Theme.of(context).colorScheme.onSurface) : Theme.of(context).colorScheme.onSurface,
),
),
);
}).toList(),
),
if (widget.showCompletionStatus && isCompletelyTaken) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.green.shade500,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'Complete',
style: TextStyle(
color: Theme.of(context).colorScheme.inverseSurface,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
],
],
),
if (widget.supplement.brand != null && widget.supplement.brand!.isNotEmpty)
Text(
widget.supplement.brand!,
style: TextStyle(
fontSize: 11,
color: widget.showCompletionStatus ? (isCompletelyTaken
? Colors.green.shade200
: isTakenToday
? Theme.of(context).colorScheme.secondary
: Theme.of(context).colorScheme.primary) : Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Row(
children: [
Text(
'${widget.supplement.frequencyPerDay}x daily • ${widget.supplement.numberOfUnits} ${widget.supplement.unitType}',
style: TextStyle(
fontSize: 11,
color: ShadTheme.of(context).colorScheme.foreground.withValues(alpha: 0.7),
),
),
if (widget.showCompletionStatus && isTakenToday && !isCompletelyTaken) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondary,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$todayIntakeCount/${widget.supplement.frequencyPerDay}',
style: TextStyle(
color: Theme.of(context).colorScheme.onSecondary,
fontSize: 9,
fontWeight: FontWeight.bold,
),
),
),
],
],
),
],
),
),
const SizedBox(height: 12),
// Schedule and dosage info
Row(
children: [
Expanded(
child: _InfoChip(
icon: Icons.schedule,
label: '${widget.supplement.frequencyPerDay}x daily',
context: context,
),
),
const SizedBox(width: 8),
Expanded(
child: _InfoChip(
icon: Icons.medication,
label: '${widget.supplement.numberOfUnits} ${widget.supplement.unitType}',
context: context,
),
),
],
),
if (widget.supplement.reminderTimes.isNotEmpty) ...[
const SizedBox(height: 8),
_InfoChip(
icon: Icons.notifications,
label: 'Reminders: ${widget.supplement.reminderTimes.join(', ')}',
context: context,
fullWidth: true,
),
],
if (widget.supplement.notes != null && widget.supplement.notes!.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
),
child: Text(
widget.supplement.notes!,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
],
const SizedBox(height: 16),
// Take button
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: isCompletelyTaken ? null : widget.onTake,
icon: Icon(
isCompletelyTaken ? Icons.check_circle : Icons.medication,
size: 18,
),
label: Text(
isCompletelyTaken
? 'All doses taken today'
: isTakenToday
? 'Take next dose'
: 'Take supplement',
style: const TextStyle(fontWeight: FontWeight.w600),
),
style: ElevatedButton.styleFrom(
backgroundColor: isCompletelyTaken
? Colors.green.shade500
: Theme.of(context).colorScheme.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: isCompletelyTaken ? 0 : 2,
),
),
),
],
),
),
@@ -489,7 +334,7 @@ class _InfoChip extends StatelessWidget {
width: fullWidth ? double.infinity : null,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.4),
color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(8),
),
child: Row(
@@ -517,3 +362,4 @@ class _InfoChip extends StatelessWidget {
);
}
}

View File

@@ -6,6 +6,14 @@
#include "generated_plugin_registrant.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) {
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
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

@@ -3,6 +3,8 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_linux
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@@ -5,12 +5,20 @@
import FlutterMacOS
import Foundation
import connectivity_plus
import flutter_local_notifications
import flutter_secure_storage_darwin
import path_provider_foundation
import shared_preferences_foundation
import sqflite_darwin
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}

View File

@@ -1,6 +1,14 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
alarm:
dependency: "direct main"
description:
name: alarm
sha256: "1eef91f0b803a2370137e0dada9c7c24cc31edf4f1c30b06442dcf486cc192e0"
url: "https://pub.dev"
source: hosted
version: "5.1.4"
args:
dependency: transitive
description:
@@ -25,6 +33,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
boxy:
dependency: transitive
description:
name: boxy
sha256: "71af0cd1bf7889c09787f26219a345aa4f38ccb98384c8ec24189e4d8e746005"
url: "https://pub.dev"
source: hosted
version: "2.2.1"
characters:
dependency: transitive
description:
@@ -49,6 +65,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
connectivity_plus:
dependency: "direct main"
description:
name: connectivity_plus
sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec
url: "https://pub.dev"
source: hosted
version: "6.1.5"
connectivity_plus_platform_interface:
dependency: transitive
description:
name: connectivity_plus_platform_interface
sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
crypto:
dependency: "direct main"
description:
name: crypto
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
url: "https://pub.dev"
source: hosted
version: "3.0.6"
cupertino_icons:
dependency: "direct main"
description:
@@ -65,6 +113,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.11"
dio:
dependency: transitive
description:
name: dio
sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9
url: "https://pub.dev"
source: hosted
version: "5.9.0"
dio_web_adapter:
dependency: transitive
description:
name: dio_web_adapter
sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
equatable:
dependency: transitive
description:
name: equatable
sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7"
url: "https://pub.dev"
source: hosted
version: "2.0.7"
extended_image:
dependency: transitive
description:
name: extended_image
sha256: f6cbb1d798f51262ed1a3d93b4f1f2aa0d76128df39af18ecb77fa740f88b2e0
url: "https://pub.dev"
source: hosted
version: "10.0.1"
extended_image_library:
dependency: transitive
description:
name: extended_image_library
sha256: "1f9a24d3a00c2633891c6a7b5cab2807999eb2d5b597e5133b63f49d113811fe"
url: "https://pub.dev"
source: hosted
version: "5.0.1"
fake_async:
dependency: transitive
description:
@@ -89,11 +177,35 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_animate:
dependency: transitive
description:
name: flutter_animate
sha256: "7befe2d3252728afb77aecaaea1dec88a89d35b9b1d2eea6d04479e8af9117b5"
url: "https://pub.dev"
source: hosted
version: "4.5.2"
flutter_fgbg:
dependency: transitive
description:
name: flutter_fgbg
sha256: eb6da9b2047372566a6e17b505975fe5bace94af01f6fc825c4b6f81baa6c447
url: "https://pub.dev"
source: hosted
version: "0.7.1"
flutter_lints:
dependency: "direct dev"
description:
@@ -134,6 +246,76 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.2"
flutter_localizations:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: f7eceb0bc6f4fd0441e29d43cab9ac2a1c5ffd7ea7b64075136b718c46954874
url: "https://pub.dev"
source: hosted
version: "10.0.0-beta.4"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: f226f2a572bed96bc6542198ebaec227150786e34311d455a7e2d3d06d951845
url: "https://pub.dev"
source: hosted
version: "0.1.0"
flutter_secure_storage_linux:
dependency: "direct overridden"
description:
path: flutter_secure_storage_linux
ref: patch-2
resolved-ref: f076cbb65b075afd6e3b648122987a67306dc298
url: "https://github.com/m-berto/flutter_secure_storage.git"
source: git
version: "2.0.1"
flutter_secure_storage_platform_interface:
dependency: "direct overridden"
description:
name: flutter_secure_storage_platform_interface
sha256: b8337d3d52e429e6c0a7710e38cf9742a3bb05844bd927450eb94f80c11ef85d
url: "https://pub.dev"
source: hosted
version: "2.0.0"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: "4c3f233e739545c6cb09286eeec1cc4744138372b985113acc904f7263bef517"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: ff32af20f70a8d0e59b2938fc92de35b54a74671041c814275afd80e27df9f21
url: "https://pub.dev"
source: hosted
version: "4.0.0"
flutter_shaders:
dependency: transitive
description:
name: flutter_shaders
sha256: "34794acadd8275d971e02df03afee3dee0f98dbfb8c4837082ad0034f612a3e2"
url: "https://pub.dev"
source: hosted
version: "0.1.3"
flutter_svg:
dependency: transitive
description:
name: flutter_svg
sha256: cd57f7969b4679317c17af6fd16ee233c1e60a82ed209d8a475c54fd6fd6f845
url: "https://pub.dev"
source: hosted
version: "2.2.0"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -152,6 +334,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.0"
http_client_helper:
dependency: transitive
description:
name: http_client_helper
sha256: "8a9127650734da86b5c73760de2b404494c968a3fd55602045ffec789dac3cb1"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
http_parser:
dependency: transitive
description:
@@ -168,6 +358,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.20.2"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
url: "https://pub.dev"
source: hosted
version: "4.9.0"
leak_tracker:
dependency: transitive
description:
@@ -200,6 +406,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
lucide_icons_flutter:
dependency: transitive
description:
name: lucide_icons_flutter
sha256: c88e3611c0aa272ca2f2aa263662174ae4996f5e3ee1c300021514df230b6588
url: "https://pub.dev"
source: hosted
version: "3.0.9"
matcher:
dependency: transitive
description:
@@ -224,6 +446,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.16.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
nested:
dependency: transitive
description:
@@ -232,6 +462,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
nm:
dependency: transitive
description:
name: nm
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
url: "https://pub.dev"
source: hosted
version: "0.5.0"
path:
dependency: "direct main"
description:
@@ -240,6 +478,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "993381400e94d18469750e5b9dcb8206f15bc09f9da86b9e44a9b0092a0066db"
url: "https://pub.dev"
source: hosted
version: "2.2.18"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
path_provider_linux:
dependency: transitive
description:
@@ -288,6 +558,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
popover:
dependency: "direct main"
description:
name: popover
sha256: "0606f3e10f92fc0459f5c52fd917738c29e7552323b28694d50c2d3312d0e1a2"
url: "https://pub.dev"
source: hosted
version: "0.3.1"
provider:
dependency: "direct main"
description:
@@ -296,6 +574,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
shadcn_ui:
dependency: "direct main"
description:
name: shadcn_ui
sha256: "628d1a7f36e4c764dae3b86b38abb086adf39ccea0073960f481777d1880f90d"
url: "https://pub.dev"
source: hosted
version: "0.29.2"
shared_preferences:
dependency: "direct main"
description:
@@ -308,10 +602,10 @@ packages:
dependency: transitive
description:
name: shared_preferences_android
sha256: "5bcf0772a761b04f8c6bf814721713de6f3e5d9d89caf8d3fe031b02a342379e"
sha256: a2608114b1ffdcbc9c120eb71a0e207c71da56202852d4aab8a5e30a82269e74
url: "https://pub.dev"
source: hosted
version: "2.4.11"
version: "2.4.12"
shared_preferences_foundation:
dependency: transitive
description:
@@ -365,6 +659,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.10.1"
sprintf:
dependency: transitive
description:
name: sprintf
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
sqflite:
dependency: "direct main"
description:
@@ -477,6 +779,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.10.1"
two_dimensional_scrollables:
dependency: transitive
description:
name: two_dimensional_scrollables
sha256: "0f77ecb96596f2f82eec2b0a8e60d9305c58315557da9fa3b610c7dbf5ded621"
url: "https://pub.dev"
source: hosted
version: "0.3.7"
typed_data:
dependency: transitive
description:
@@ -485,6 +795,110 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
universal_image:
dependency: transitive
description:
name: universal_image
sha256: ef47a4a002158cf0b36ed3b7605af132d2476cc42703e41b8067d3603705c40d
url: "https://pub.dev"
source: hosted
version: "1.0.11"
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:
dependency: "direct main"
description:
name: uuid
sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff
url: "https://pub.dev"
source: hosted
version: "4.5.1"
vector_graphics:
dependency: transitive
description:
name: vector_graphics
sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6
url: "https://pub.dev"
source: hosted
version: "1.1.19"
vector_graphics_codec:
dependency: transitive
description:
name: vector_graphics_codec
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
url: "https://pub.dev"
source: hosted
version: "1.1.13"
vector_graphics_compiler:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc
url: "https://pub.dev"
source: hosted
version: "1.1.19"
vector_math:
dependency: transitive
description:
@@ -509,6 +923,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
webdav_client:
dependency: "direct main"
description:
name: webdav_client
sha256: "682fffc50b61dc0e8f46717171db03bf9caaa17347be41c0c91e297553bf86b2"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
win32:
dependency: transitive
description:
name: win32
sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03"
url: "https://pub.dev"
source: hosted
version: "5.14.0"
xdg_directories:
dependency: transitive
description:
@@ -527,4 +957,4 @@ packages:
version: "6.6.1"
sdks:
dart: ">=3.9.0 <4.0.0"
flutter: ">=3.27.0"
flutter: ">=3.32.0"

View File

@@ -1,38 +1,14 @@
name: supplements
description: "A new Flutter project."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
description: "A supplement tracking app for managing your daily supplements"
publish_to: "none"
version: 1.0.10+05092025
environment:
sdk: ^3.9.0
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
# Local database for storing supplements data
@@ -53,55 +29,32 @@ dependencies:
# Date time handling
intl: ^0.20.2
# UI components
shadcn_ui: ^0.29.2
popover: ^0.3.0
# WebDAV sync functionality
webdav_client: ^1.2.2
connectivity_plus: ^6.1.5
flutter_secure_storage: ^10.0.0-beta.4
uuid: ^4.5.1
crypto: ^3.0.6
url_launcher: ^6.3.2
alarm: ^5.1.4
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
dependency_overrides:
flutter_secure_storage_linux:
git:
url: https://github.com/m-berto/flutter_secure_storage.git
ref: patch-2
path: flutter_secure_storage_linux
flutter_secure_storage_platform_interface: 2.0.0
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package

View File

@@ -1,30 +0,0 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:supplements/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}

View File

@@ -6,6 +6,15 @@
#include "generated_plugin_registrant.h"
#include <connectivity_plus/connectivity_plus_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) {
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}

View File

@@ -3,6 +3,9 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus
flutter_secure_storage_windows
url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST